返回文章列表

PostgreSQL VACUUM、Autovacuum、Freeze、Wraparound 与 Bloat

从 Dead Tuple、Page Pruning、Autovacuum 触发阈值、Freeze、Wraparound、表与索引膨胀到生产 Runbook,系统理解 PostgreSQL 长期维护机制。

第 12 章:VACUUM、Autovacuum、Freeze、Wraparound 与 Bloat

技术基线:PostgreSQL 18;兼顾 PostgreSQL 14—17。示例默认在隔离测试库执行。除非明确标为“示例值”,参数值都只是在说明机制,不构成通用生产建议。

1. 本章定位

PostgreSQL 的 UPDATEDELETE 通常不会立刻在原地抹掉旧行版本:UPDATE 创建新 Tuple,旧 Tuple 进入不可见但尚待回收的状态;DELETE 先标记删除,之后也要等待安全清理。这个设计是 MVCC 高并发读取的基础,但同时产生四类长期成本:

  1. Heap 和索引里积累不可复用或尚未清理的版本,形成读放大、写放大和空间放大;
  2. Visibility Map 不能及时恢复,Index Only Scan 退化为频繁 Heap Fetch;
  3. Planner 统计信息陈旧,执行计划偏离真实数据分布;
  4. 事务 ID 与 MultiXact ID 持续消耗,极端情况下触发反环绕保护并停止新的事务 ID 分配。

本章解决的是“数据库运行几个月或几年后,为什么没有明显业务增长却越来越慢、越来越大,以及怎样在不中断服务的前提下控制这种退化”。它依赖第 3 章的 Page/Tuple/HOT、第 9 章的 MVCC/Snapshot 和第 11 章的锁;下一章将继续解释 VACUUM 产生的 WAL、Checkpoint 与复制重放成本。

本章不展开 B-tree 分裂算法、WAL Record 二进制格式、备份恢复流程和完整复制协议;只说明它们与 VACUUM 的边界。

2. 可验证的学习目标

完成本章后,你应能够:

  • 根据 Tuple 的 xmin/xmax、快照边界和 backend_xmin,解释 Dead 与 Recently Dead 的差异;
  • 画出 Page Pruning、HOT Pruning、Lazy VACUUM、索引清理、FSM/VM 更新和尾部截断的完整路径;
  • 用表规模和变更速率计算 Autovacuum 触发阈值,并识别默认 scale factor 对超大表的失效场景;
  • pg_stat_all_tablespg_stat_progress_vacuumpg_stat_activitypg_prepared_xactspg_replication_slots 定位清理地平线;
  • 准确区分 replication slot 的 WAL 保留 (restart_lsn) 与 Tuple 回收地平线 (xmin/catalog_xmin);
  • 复现长事务让 VACUUM 无法删除旧版本的现象;
  • 判断应使用普通 VACUUMVACUUM FULLREINDEX [CONCURRENTLY] 还是 pg_repack
  • 分析一次维护对 CPU、内存、shared buffers、OS Page Cache、I/O、WAL、Checkpoint、主从延迟和 P99 的影响;
  • 实现一个基于 pgxpool 的有界并发健康检查器,识别长事务、idle in transaction、维护债务和环绕风险;
  • 在出现反环绕告警、表膨胀或副本冲突时执行可审计的生产 Runbook,并验证修复结果。

3. 核心术语

中文名称English准确定义容易混淆的概念层次
死行版本Dead Tuple已不可能被任何相关快照看到、可由 VACUUM 回收的 Heap Tuple“被 DELETE”不等于立即 DeadTuple/MVCC
近期死亡版本Recently Dead对当前判断已删除,但仍可能被某个较老快照看到,暂不可移除Dead TupleTuple/MVCC
页面剪枝Page Pruning在单个 Heap Page 内清理可移除版本、重定向 HOT 链并压实可用空间完整 VACUUM;它不清理索引Heap Page
HOT 剪枝HOT Pruning对 HOT 链中的中间版本进行页面内回收和 line pointer 重定向HOT UpdateHeap Page
Lazy VACUUMLazy VACUUM普通、非 FULL 的 VACUUM 算法族:低强度锁下扫描 Heap、清索引、回收 Heap、更新 VM/FSMVACUUM FULL维护流程
全量真空VACUUM FULL重写表到新物理文件并重建索引,以紧凑布局返还空间普通 VACUUM表重写/锁
自动清理AutovacuumLauncher 调度 Worker,按统计阈值执行 VACUUM/ANALYZE,并承担反环绕清理“后台一直在跑”不代表赶得上后台进程
冻结Freeze将足够老且已提交的插入事务视为永久可见,解除其 XID 环绕风险物理删除Tuple/XID
事务 ID 环绕XID Wraparound32 位 XID 循环使用后,新旧顺序可能反转;必须通过冻结维持可见性语义XID 耗尽只是表象集群安全
反环绕 VACUUMAnti-wraparound Vacuum由 XID/MultiXact 年龄触发的强制维护,优先保证可用性安全而非平滑负载普通阈值 VACUUM后台维护
MultiXactMultiXact ID表示多个事务共同持有 Tuple 锁的 ID;也会环绕并需冻结/截断成员存储普通 XIDTuple Lock
表冻结下界relfrozenxid某 Relation 中所有更老 XID 均已冻结的保守下界“表中最老 xmin”的精确值系统目录
数据库冻结下界datfrozenxid数据库各需维护 Relation 的冻结下界汇总集群级绝对下界系统目录
自由空间映射FSM记录 Heap/Index Page 可用空间,帮助插入或更新寻找可容纳页面VM辅助 Fork
可见性映射Visibility Map每个 Heap Page 两个 bit:all-visible 与 all-frozenFSM;索引自身没有 VM辅助 Fork
索引清理Index Cleanup删除或标记可复用的死 Index Tuple/页面项,解除 Heap TID 引用重建索引Index
尾部截断Truncation普通 VACUUM 在表末尾连续空页满足条件时缩短 Relation 文件全表压缩Storage/Lock
表膨胀Table BloatHeap 文件中长期不能有效复用的死版本、碎片和空页导致的额外空间/扫描成本所有 free space 都是浪费Heap
索引膨胀Index Bloat死索引项、页分裂、低密度页等造成的额外索引层级与 I/O表膨胀Index
维护内存maintenance_work_mem手工 VACUUM 等维护操作可使用的内存上限之一work_memBackend Memory
自动维护内存autovacuum_work_mem每个 Autovacuum Worker 的维护内存上限;-1 表示继承 maintenance_work_mem全局总内存Worker Memory
成本延迟Vacuum Cost Delay用命中、读取、弄脏页面的成本积分与周期睡眠对 VACUUM 节流I/O 限速器的精确带宽调度/负载
估算死行数n_dead_tup统计子系统维护的估算值,不是逐行精确计数pgstattuple 的扫描结果统计信息
WAL 保留点restart_lsnSlot 仍可能需要的最早 WAL 位置Tuple 回收地平线Replication Slot
Tuple 回收地平线xmin/catalog_xminSlot 声明仍可能需要的普通表/系统目录行版本下界restart_lsnReplication Slot

4. 整体心智模型

PostgreSQL VACUUM、Autovacuum、Freeze、Wraparound 与 Bloat flow 1

4.1 数据流

DML 把新版本写入 Heap,并按是否 HOT 更新决定是否新增索引项。旧版本不立即删除;当所有可能看到它的快照都越过其删除事务后,它才从 Recently Dead 转为可安全移除。普通 VACUUM 将 Heap 中可删除 Tuple 的 TID 批量交给各索引清理,再回到 Heap 释放 line pointer/空间,更新 FSM 和 VM。

4.2 控制流

Autovacuum Launcher 周期性查看数据库与统计信息,启动 Worker;Worker 根据更新/删除阈值、插入阈值、ANALYZE 阈值以及 XID/MultiXact 年龄选择表。手工 VACUUM 与 Worker 共用主要算法,但使用不同的内存、成本延迟和权限入口。反环绕任务优先级更高,接近 failsafe 时会牺牲非必要索引清理和成本节流来尽快推进冻结边界。

4.3 状态变化

LIVE
  ├─ UPDATE/DELETE 提交,但旧快照仍存在 ─> RECENTLY_DEAD
  │                                      └─ 旧地平线消失 ─> DEAD
  └─ 老且已提交、VACUUM Freeze ─────────> FROZEN(逻辑上永久可见)

Heap Page: 有死版本 -> prune/vacuum -> 可复用空间 -> FSM 更新
VM bit:    DML 清除   -> vacuum 验证整页可见/冻结 -> 重新设置
Catalog:   vacuum 推进 relfrozenxid/relminmxid -> database 汇总 datfrozenxid/datminmxid

现代 PostgreSQL 的“冻结”主要是设置 Tuple Header 的冻结语义标志;不要把它简单理解成每次都把物理 xmin 字段改写为某个特殊数字。

4.4 故障路径

  • 长事务、idle in transaction、未决 Prepared Transaction 或 Slot 的 xmin 让全局可见性地平线无法前进;VACUUM 能扫描,却不能移除相关旧版本。
  • Autovacuum Worker 数量、I/O 或成本预算不足,新增垃圾速度持续高于清理速度,维护债务单调增长。
  • hot_standby_feedback=on 把副本长查询的 horizon 反馈到主库,减少副本取消,但可能让主库保留大量旧版本。
  • VACUUM FULL、表重写或索引重建在磁盘、锁、WAL、归档或副本重放容量不足时放大故障。
  • XID/MultiXact 年龄持续上升时,PostgreSQL 会先告警、再进入强制维护,最终为防止数据可见性错误而拒绝分配新 XID。

5. 使用方式

5.1 先确认版本、配置与观测前提

SELECT version();

SHOW autovacuum;
SHOW track_counts;
SHOW autovacuum_max_workers;
SHOW autovacuum_worker_slots;              -- [PG18]
SHOW autovacuum_naptime;
SHOW autovacuum_vacuum_threshold;
SHOW autovacuum_vacuum_scale_factor;
SHOW autovacuum_vacuum_max_threshold;       -- [PG18]
SHOW autovacuum_vacuum_insert_threshold;
SHOW autovacuum_vacuum_insert_scale_factor;
SHOW autovacuum_freeze_max_age;
SHOW autovacuum_multixact_freeze_max_age;
SHOW autovacuum_vacuum_cost_delay;
SHOW autovacuum_vacuum_cost_limit;
SHOW maintenance_work_mem;
SHOW autovacuum_work_mem;
SHOW vacuum_buffer_usage_limit;             -- [PG16+]
SHOW vacuum_truncate;                       -- [PG18]
SHOW hot_standby_feedback;

track_counts 必须开启,Autovacuum 才能依赖正常统计阈值工作。不要把一次 SHOW 的默认值当成调优结论;先记录数据规模、行宽、每秒变更行数、读写比例、峰值并发、内存、CPU、存储时延/IOPS、归档吞吐和查询 SLO。

5.2 普通 VACUUM、ANALYZE 与 FULL

-- 日常维护:允许并发读写;不能放在显式事务块中执行。
VACUUM (VERBOSE, ANALYZE) app.orders;

-- 紧急情况下可先跳过索引清理以推进 Heap/Freeze;后续必须补做索引治理。
VACUUM (VERBOSE, INDEX_CLEANUP OFF) app.orders;

-- [PG16+] 限定 Buffer Access Strategy;示例值不是建议值。
VACUUM (ANALYZE, BUFFER_USAGE_LIMIT '256MB') app.orders;

-- 仅在已评估锁、临时磁盘、WAL、复制和回滚方案后执行。
VACUUM (FULL, VERBOSE, ANALYZE) app.orders;

-- ANALYZE 可独立运行,触发条件与 VACUUM 不同。
ANALYZE (VERBOSE) app.orders;

必须记住:

  • 普通 VACUUM 主要让空间在 Relation 内部复用,通常不会把空间返还操作系统;只有表尾连续空页可截断时,文件才可能缩短。
  • VACUUM FULL 重写整张表并取得 ACCESS EXCLUSIVE,会阻塞普通读写,且需要额外磁盘;它不是“更彻底的日常 VACUUM”。
  • VACUUM 不能在事务块中执行。运行权限通常需要表所有者、数据库所有者、超级用户或具有相应 MAINTAIN 权限的角色。
  • INDEX_CLEANUP OFF 是权衡工具,不是长期设置;它会留下可继续膨胀的死索引项。
  • TRUNCATE OFF 可避免普通 VACUUM 为尾部截断尝试较强锁,但会放弃当次文件缩短机会。

5.3 Autovacuum 触发公式

以 PG18 为基线,更新/删除型触发阈值近似为:

uncapped_trigger = autovacuum_vacuum_threshold
  + autovacuum_vacuum_scale_factor × reltuples

vacuum_trigger =
  if autovacuum_vacuum_max_threshold < 0:
      uncapped_trigger
  else:
      min(autovacuum_vacuum_max_threshold, uncapped_trigger)

其中 autovacuum_vacuum_max_threshold 是 [PG18] 新增的上限;配置为负值时不限制。插入型触发阈值考虑尚未 all-frozen 的页面比例:

insert_trigger = autovacuum_vacuum_insert_threshold
  + autovacuum_vacuum_insert_scale_factor × reltuples
    × (1 - relallfrozen / relpages)

ANALYZE 的近似阈值为:

analyze_trigger = autovacuum_analyze_threshold
  + autovacuum_analyze_scale_factor × reltuples

这些公式使用统计估算,因此触发不是精确到某一行或某一毫秒。Launcher/Worker 还受轮询周期、Worker 槽位、当前工作队列、锁、成本延迟和主机资源约束。

一个十亿行表若沿用 scale_factor=0.2,仅比例项就可能要求约两亿次更新/删除后才触发;这通常远大于可接受维护窗口。因此大表应按“每秒产生多少不可见版本 × 最大允许垃圾存活时间”反推阈值,而不是照抄固定比例。

5.4 每表参数

-- 仅为语法示例。真实值必须由工作负载、SLO 与容量实验得出。
ALTER TABLE app.orders SET (
    autovacuum_enabled = true,
    autovacuum_vacuum_threshold = 20000,
    autovacuum_vacuum_scale_factor = 0.005,
    autovacuum_vacuum_insert_threshold = 50000,
    autovacuum_vacuum_insert_scale_factor = 0.01,
    autovacuum_analyze_threshold = 10000,
    autovacuum_analyze_scale_factor = 0.01,
    autovacuum_vacuum_cost_delay = 2,
    autovacuum_vacuum_cost_limit = 1000,
    autovacuum_freeze_min_age = 50000000,
    vacuum_index_cleanup = 'auto',
    vacuum_truncate = true
);

-- 恢复继承系统级配置。
ALTER TABLE app.orders RESET (
    autovacuum_vacuum_threshold,
    autovacuum_vacuum_scale_factor,
    autovacuum_vacuum_insert_threshold,
    autovacuum_vacuum_insert_scale_factor,
    autovacuum_analyze_threshold,
    autovacuum_analyze_scale_factor,
    autovacuum_vacuum_cost_delay,
    autovacuum_vacuum_cost_limit,
    autovacuum_freeze_min_age,
    vacuum_index_cleanup,
    vacuum_truncate
);

绝不要因为某张表产生延迟尖峰就随意关闭全局 Autovacuum。即使因实验暂时对单表设置 autovacuum_enabled=false,也必须有显式维护和环绕监控;PostgreSQL 仍可能为反环绕安全强制处理该表,这不是可依赖的日常策略。

5.5 估算实际触发阈值

以下查询兼顾全局设置和常用每表 override,并强调 n_dead_tup 只是估算:

WITH g AS (
    SELECT
        current_setting('autovacuum_vacuum_threshold')::numeric AS base_threshold,
        current_setting('autovacuum_vacuum_scale_factor')::numeric AS scale_factor,
        COALESCE(
            current_setting('autovacuum_vacuum_max_threshold', true), '-1'
        )::numeric AS max_threshold
), rel AS (
    SELECT
        c.oid,
        n.nspname,
        c.relname,
        GREATEST(c.reltuples, 0)::numeric AS reltuples,
        s.n_live_tup,
        s.n_dead_tup,
        s.last_vacuum,
        s.last_autovacuum,
        COALESCE((
            SELECT option_value::numeric
            FROM pg_options_to_table(c.reloptions)
            WHERE option_name = 'autovacuum_vacuum_threshold'
        ), g.base_threshold) AS base_threshold,
        COALESCE((
            SELECT option_value::numeric
            FROM pg_options_to_table(c.reloptions)
            WHERE option_name = 'autovacuum_vacuum_scale_factor'
        ), g.scale_factor) AS scale_factor,
        COALESCE((
            SELECT option_value::numeric
            FROM pg_options_to_table(c.reloptions)
            WHERE option_name = 'autovacuum_vacuum_max_threshold'
        ), g.max_threshold) AS max_threshold
    FROM pg_class AS c
    JOIN pg_namespace AS n ON n.oid = c.relnamespace
    JOIN pg_stat_all_tables AS s ON s.relid = c.oid
    CROSS JOIN g
    WHERE c.relkind IN ('r', 'm')
      AND n.nspname NOT IN ('pg_catalog', 'information_schema')
), calc AS (
    SELECT *,
        base_threshold + scale_factor * reltuples AS uncapped_trigger
    FROM rel
)
SELECT
    format('%I.%I', nspname, relname) AS relation,
    reltuples::bigint,
    n_live_tup,
    n_dead_tup,
    CASE WHEN max_threshold < 0
         THEN uncapped_trigger
         ELSE LEAST(max_threshold, uncapped_trigger)
    END::bigint AS estimated_vacuum_trigger,
    last_vacuum,
    last_autovacuum
FROM calc
ORDER BY n_dead_tup DESC;

该查询仍不等价于 Autovacuum 内部调度器:统计快照、Worker 竞争、插入触发、冻结年龄、分区/TOAST 关系和并发变化都会影响实际选择。

5.6 日常诊断 SQL

表级维护债务:

SELECT
    relid::regclass AS relation,
    pg_size_pretty(pg_total_relation_size(relid)) AS total_size,
    n_live_tup,
    n_dead_tup,
    round(100.0 * n_dead_tup / NULLIF(n_live_tup + n_dead_tup, 0), 2) AS dead_pct_est,
    n_tup_upd,
    n_tup_hot_upd,
    n_mod_since_analyze,
    n_ins_since_vacuum,
    last_vacuum,
    last_autovacuum,
    vacuum_count,
    autovacuum_count,
    last_analyze,
    last_autoanalyze,
    total_vacuum_time,          -- [PG18]
    total_autovacuum_time       -- [PG18]
FROM pg_stat_user_tables
ORDER BY pg_total_relation_size(relid) DESC;

n_live_tupn_dead_tup 都是估算;统计可能刚被 reset,也可能尚未反映最新事务。趋势、变化速率和实际扫描结果比单次百分比更重要。

正在运行的 VACUUM:

SELECT
    p.pid,
    p.relid::regclass AS relation,
    p.phase,
    p.heap_blks_total,
    p.heap_blks_scanned,
    p.heap_blks_vacuumed,
    p.index_vacuum_count,
    p.max_dead_tuple_bytes,     -- [PG17+]
    p.dead_tuple_bytes,         -- [PG17+]
    p.num_dead_item_ids,        -- [PG17+]
    p.indexes_total,
    p.indexes_processed,
    p.delay_time                -- [PG18,且需 track_cost_delay_timing]
FROM pg_stat_progress_vacuum AS p;

普通 VACUUM 的典型 phase 包括 initializing、scanning heap、vacuuming indexes、vacuuming heap、cleaning up indexes、truncating heap。VACUUM FULL 属于表重写,应查看 pg_stat_progress_cluster,而不是这个视图。

长事务与 idle in transaction:

SELECT
    pid, datname, usename, application_name, client_addr,
    state, xact_start, query_start,
    clock_timestamp() - xact_start AS xact_age,
    backend_xmin,
    age(backend_xmin) AS xmin_age,
    wait_event_type, wait_event,
    left(query, 240) AS query_sample
FROM pg_stat_activity
WHERE pid <> pg_backend_pid()
  AND xact_start IS NOT NULL
ORDER BY xact_start;

xact_start 很老但查询很短,常见原因是应用开启事务后在网络、日志、RPC 或人工交互上停留。state='idle in transaction' 表示当前没有执行 SQL,却仍保留事务上下文;在 Repeatable Read/Serializable 或已取得快照的场景中,它尤其可能固定清理地平线。

Prepared Transaction:

SELECT
    transaction, gid, prepared,
    clock_timestamp() - prepared AS prepared_age,
    owner, database
FROM pg_prepared_xacts
ORDER BY prepared;

Prepared Transaction 已与原客户端会话分离,不会因连接关闭自动结束;必须由事务协调器明确 COMMIT PREPAREDROLLBACK PREPARED

Slot 的两类保留:

WITH wal_position AS (
    SELECT CASE
             WHEN pg_is_in_recovery() THEN pg_last_wal_replay_lsn()
             ELSE pg_current_wal_lsn()
           END AS current_lsn
)
SELECT
    s.slot_name, s.slot_type, s.database, s.active,
    s.restart_lsn,
    pg_size_pretty(
        COALESCE(pg_wal_lsn_diff(w.current_lsn, s.restart_lsn), 0)
    ) AS retained_wal,
    s.xmin,
    age(s.xmin) AS xmin_age,
    s.catalog_xmin,
    age(s.catalog_xmin) AS catalog_xmin_age,
    s.wal_status,
    s.safe_wal_size,
    s.invalidation_reason
FROM pg_replication_slots AS s
CROSS JOIN wal_position AS w
ORDER BY s.slot_name;

restart_lsn 决定 WAL 文件能否回收;xmin/catalog_xmin 决定相关 Tuple 能否被 VACUUM 删除。一个 Slot 可以只造成 WAL 堆积,也可以同时固定 Tuple/Catalog 清理边界,必须分别告警。

XID 与 MultiXact 风险:

SELECT
    datname,
    age(datfrozenxid) AS xid_age,
    current_setting('autovacuum_freeze_max_age')::bigint AS xid_trigger,
    mxid_age(datminmxid) AS multixact_age,
    current_setting('autovacuum_multixact_freeze_max_age')::bigint AS multixact_trigger
FROM pg_database
ORDER BY age(datfrozenxid) DESC;

SELECT
    c.oid::regclass AS relation,
    age(c.relfrozenxid) AS xid_age,
    mxid_age(c.relminmxid) AS multixact_age,
    pg_size_pretty(pg_total_relation_size(c.oid)) AS total_size
FROM pg_class AS c
JOIN pg_namespace AS n ON n.oid = c.relnamespace
WHERE c.relkind IN ('r', 'm', 't')
ORDER BY age(c.relfrozenxid) DESC
LIMIT 50;

环绕排查不要过滤 pg_catalog:系统目录也可能成为数据库冻结下界的来源。Relation 查询只能看到当前连接数据库;数据库级查询必须覆盖 pg_database 中全部数据库,包括不可连接的模板库。

不要只看比例告警,还要看“按当前 XID 消耗速率还剩多少时间”。促销、批处理、重试风暴会显著改变消耗斜率。

5.7 pgstattuple、REINDEX 与在线重建

CREATE EXTENSION IF NOT EXISTS pgstattuple;

-- 大表先用近似扫描;仍会读取一部分页面。
SELECT * FROM pgstattuple_approx('app.orders'::regclass);

-- 精确统计会扫描整个 Relation,成本高于估算视图。
SELECT * FROM pgstattuple('app.orders'::regclass);

-- B-tree 物理密度与碎片。
SELECT * FROM pgstatindex('app.orders_status_idx'::regclass);

-- 只修复索引,不会消除 Heap bloat。
REINDEX INDEX CONCURRENTLY app.orders_status_idx;

REINDEX CONCURRENTLY 允许正常 DML,但需要多阶段扫描、额外磁盘与更长时间,并可能等待旧快照;失败时可能留下 invalid index,需要按日志与目录状态清理。普通 REINDEX 锁更强。

pg_repack 是外部扩展,不是 PostgreSQL Core 的“零锁 VACUUM FULL”。完整表 repack 通常要求主键或覆盖全列且均为 NOT NULL 的唯一索引;它通过日志表/触发器捕获并发 DML、构建副本、追赶变更并最终交换对象。多数阶段锁较轻,但开始和最终交换仍需短暂 ACCESS EXCLUSIVE;持续高写入可能无法追平,且需显著额外磁盘并产生大量 WAL。DDL、复制/CDC、触发器语义和扩展版本都必须预演。

6. 底层原理

6.1 从 UPDATE 到可回收空间的时间线

假设 T1 在 Repeatable Read 中读取行 R0,随后 T2 更新该行并提交:

t0  T1 BEGIN ISOLATION LEVEL REPEATABLE READ
    T1 SELECT R0                  -- Snapshot S1 能看到 R0

t1  T2 UPDATE R0 -> R1
    Heap: R0.xmax = T2, 新增 R1.xmin = T2
    若更新索引列或同页无空间:新增索引项指向 R1

t2  T2 COMMIT
    新事务看到 R1;T1 仍必须看到 R0
    R0 = RECENTLY_DEAD,相对全局地平线不可删除

t3  VACUUM 扫描
    看到 T1.backend_xmin/Snapshot 仍老于 T2
    不删除 R0;索引中指向 R0 的项也必须保留

t4  T1 COMMIT
    旧快照消失

t5  下一次 prune/VACUUM
    R0 = DEAD,可移除;空间进入同页复用/FSM
    对应索引项在 Index Cleanup 中删除或标记可复用

关键不是“删除事务已经提交多久”,而是系统中是否仍存在有资格看到旧版本的 Snapshot/Horizon。VACUUM 计算的是安全边界,不会为了节省空间破坏 MVCC。

6.2 Page Pruning 与 HOT Pruning

Heap Page 通过 line pointer 指向 Tuple。普通非 HOT 更新常形成:

Index key -> LP1 -> old tuple R0
Index key -> LP2 -> new tuple R1

若满足 HOT 条件——没有修改需要逐行维护的索引键,且同一 Heap Page 有足够空间——索引仍指向 HOT 链根:

Index key -> LP1(root) -> R0 -> R1 -> R2

当 R0/R1 对所有快照都不再可见时,Page Pruning 可把链压缩:

Index key -> LP1(redirect) ------> R2
             R0/R1 空间被压实并可在本页复用

这种剪枝可能由普通 SELECTUPDATEDELETE 在访问页面时机会性触发,前提是能非阻塞取得 Buffer Cleanup Lock。它只解决该 Heap Page 内部空间与 HOT 链,不遍历、删除普通索引中的死项,因此不能替代 VACUUM。fillfactor 留出页内空间可提高 HOT 概率,但会主动增加初始空间占用;应以 n_tup_hot_upd / n_tup_upd、页密度和实际索引写入成本验证。

6.3 Lazy VACUUM 的阶段

普通 VACUUM 通常按以下状态机运行:

  1. 初始化:确定 Relation、Buffer Access Strategy、冻结 cutoff、是否需要 aggressive/failsafe、索引清理和截断策略。
  2. 扫描 Heap:逐页判断 VM、Tuple 可见性和可冻结状态;执行 pruning;记录可删除 Tuple 的 TID;设置或清除 VM 条件。
  3. 批次边界:当 Dead TID 存储达到内存预算时,暂停 Heap 扫描,进入索引/Heap 清理;之后继续下一批。一个 Vacuum 出现多次 index_vacuum_count 往往意味着死项很多或内存预算较小。
  4. Vacuum Indexes:各索引访问方法按 TID 删除死项。INDEX_CLEANUP=AUTO 可在收益很低时跳过;ON 强制尝试;OFF 跳过。
  5. Vacuum Heap:清理已解除索引引用的 line pointer,更新 FSM,并验证 all-visible/all-frozen 条件。
  6. Cleanup Indexes:做索引访问方法的收尾,例如回收可用页。
  7. Truncate Heap:若表尾存在连续空页,尝试获取足以安全截断的锁并缩短文件。若高并发或 TRUNCATE OFF,可跳过。
  8. 更新目录/统计:推进 relfrozenxidrelminmxid,更新统计计数与维护时间。

[PG17+] VACUUM 使用新的 Dead TID 内存管理,pg_stat_progress_vacuum 用 byte 维度报告 max_dead_tuple_bytesdead_tuple_bytesnum_dead_item_ids。这改善了内存利用,但不意味着 maintenance_work_mem 可以无限增大:手工维护并发数、Autovacuum Worker 数和索引类型仍决定总内存上界。

6.4 FSM、Visibility Map 与 Index Only Scan

FSM 与 VM 都是 Relation 的辅助 Fork,但职责完全不同:

  • FSM 回答“哪个 Page 可能有足够 free space”;它减少插入/非 HOT 更新寻找页面的扫描成本。
  • VM all-visible 回答“这个 Heap Page 上所有 Tuple 对所有当前和未来事务都可见,索引扫描可否跳过 Heap 可见性检查”。
  • VM all-frozen 进一步表示该页 Tuple 不再需要未来 anti-wraparound 冻结处理。

DML 会保守地清除 VM bit;只有 VACUUM 在检查整页后设置它。因此一个覆盖索引即便包含查询所需全部列,也只有在相关 Heap Page 为 all-visible 时才能真正减少 Heap Fetch:

EXPLAIN (ANALYZE, BUFFERS, SETTINGS, VERBOSE, SUMMARY)
SELECT order_id, created_at
FROM app.orders
WHERE tenant_id = $1
ORDER BY created_at DESC
LIMIT $2;

观察 Index Only ScanHeap Fetches。高频更新、Autovacuum 落后或长事务固定地平线时,VM 恢复变慢,计划节点仍叫 Index Only Scan,但实际访问 Heap 的次数可能大幅上升。

6.5 索引清理、表尾截断与“为什么文件没变小”

普通 VACUUM 释放的空间首先留在 Relation 内部复用。原因是中间空页不能简单从操作系统文件中“挖洞”并改变所有 Block Number;索引 TID、Buffer Tag 和 WAL 都依赖稳定的 (block, offset) 定位。只有文件末尾连续空 Block 可以安全截断。

因此应分别问:

  1. 未来写入能否复用空间? 看 FSM、稳定态文件大小和增长斜率;
  2. 查询是否仍扫描大量空/低密度页? 看 Buffers、I/O 与 pgstattuple
  3. 是否必须立即把空间返还文件系统? 若是,才评估表重写、分区替换或在线 repack。

索引也类似:VACUUM 删除死索引项后,页面通常可被后续插入复用,但不保证把索引文件压缩到最小。REINDEX 重新按现存行构建紧凑结构;REINDEX TABLE 只重建该表的索引,不会重写 Heap。

6.6 Vacuum Cost、内存与 Buffer Access Strategy

成本节流为页面操作累计分值,例如命中 shared buffers、从存储读入、弄脏页面的成本不同;达到 vacuum_cost_limit 后睡眠 vacuum_cost_delay。它是反馈式负载平滑,不是精确的 MB/s 限流:缓存冷热、页面是否脏、存储延迟、并行索引清理和 AIO 都会改变同一 cost 下的真实吞吐。

  • 手工 VACUUM 默认成本延迟通常为 0,可能更快也更容易冲击前台。
  • Autovacuum 默认有专用 delay,并在多个 Worker 间协调全局预算;每表显式 cost 参数会改变这一关系。
  • maintenance_work_mem 影响手工维护;autovacuum_work_mem 是每个 Worker 上限。总内存风险近似是“并发维护进程 × 每进程上限”,而非单个参数值。
  • [PG16+] vacuum_buffer_usage_limit/BUFFER_USAGE_LIMIT 控制维护使用的环形 Buffer Access Strategy。太小会重复读取,太大可能逐出前台热点页;0 允许使用任意数量 shared buffers,风险更高。
  • 操作系统 Page Cache 仍参与实际文件缓存。即使 VACUUM 使用小 shared-buffer ring,扫描也可能推动 OS Cache,挤出其他工作集。

6.7 Freeze、relfrozenxiddatfrozenxid

PostgreSQL 的普通 XID 是 32 位环形空间。可见性比较使用“相对当前 XID 的前后半环”语义,因此某个 Tuple 的插入 XID 不能永远保持为普通历史 XID。VACUUM 对足够老、已提交且以后对所有事务都可见的 Tuple 设置冻结语义,使其不再参与普通 XID 年龄判断。

relfrozenxid 表示该 Relation 中不存在比它更老且仍需冻结的普通 XID,是保守下界,不是逐 Tuple 最小 xmin 的精确聚合。datfrozenxid 是数据库级汇总;集群还需跨数据库检查最老值。推进过程大致为:

扫描所有需要处理的 Heap/TOAST Page
  -> 冻结满足 cutoff 的 Tuple
  -> 确认没有遗漏更老 XID
  -> 推进 pg_class.relfrozenxid
  -> 数据库汇总推进 pg_database.datfrozenxid
  -> 可截断更老的 pg_xact 状态段

VACUUM (FREEZE) 近似把本次冻结最小年龄设为 0,适合静态装载、模板或明确维护窗口;它不是日常“性能按钮”,会增加页面访问、WAL 与脏页。VACUUM FULL 也不是环绕应急首选:它需要强锁和额外磁盘,可能在最紧急时反而无法完成。

6.8 Anti-Wraparound、Failsafe 与停写边界

当表的 XID/MultiXact 年龄超过 autovacuum_freeze_max_age 或对应 MultiXact 阈值时,即使普通变更阈值未达到,也会启动 anti-wraparound VACUUM。接近 failsafe 年龄后,维护会优先推进冻结,可绕过成本延迟并跳过非必要索引清理/常规 Buffer 策略。

官方保护流程大致经历:

  • 提前很久持续告警并强制 aggressive vacuum;
  • 距离环绕只剩约四千万个事务时,日志发出更强警告;
  • 距离约三百万个事务时,为防止可见性灾难,系统拒绝分配新 XID,写事务和许多维护操作失败。

这些绝对数字属于引擎安全边界,不应拿来设业务告警。业务告警必须更早,并结合 XID/s 消耗速率计算剩余时间。紧急恢复顺序通常是:结束或处理最老长事务、Prepared Transaction 和固定 horizon 的 Slot,再对最老表执行普通 VACUUM;不要在危急时盲目用 FULL 或同时启动大量重写。

6.9 MultiXact 的独立风险

当多个事务对同一 Tuple 持有 FOR SHAREFOR KEY SHARE 等锁时,Tuple Header 不能只放一个事务 ID,于是引用 MultiXact。MultiXact ID 自身是 32 位,成员列表还存放在独立 SLRU 中:

  • relminmxid/datminmxid 对应其维护下界;
  • mxid_age() 衡量年龄;
  • 热点行、外键检查和大量并发 Tuple Lock 会显著加快成员消耗;
  • 即使普通 XID 年龄健康,MultiXact 也可能先成为瓶颈;
  • 极端成员文件增长可能先触发更积极维护或存储压力。

因此“只监控 age(datfrozenxid)”是不完整的。

6.10 四类清理地平线阻塞者

长事务与 idle in transaction

长事务可能保留 Snapshot 和 backend_xmin。即使它只读一行,影响范围也可能是整个数据库中在其 horizon 之后删除的版本。idle in transaction 还会占用连接、持锁,并把应用网络等待变成数据库事务时间。

Prepared Transaction

PREPARE TRANSACTION 把事务状态持久化。原连接消失不代表事务结束;未决 2PC 可长期持有 XID、锁和可见性影响。没有可靠协调器时,应保持 max_prepared_transactions=0

Replication Slot

必须拆成两个问题:

restart_lsn --------------> WAL 文件保留、pg_wal/归档压力
xmin/catalog_xmin --------> 普通表/系统目录 Tuple 不能清理

物理 Slot 常见主要风险是 WAL 保留;逻辑 Slot 通常还会持有 catalog_xmin。具体状态以视图为准,不能仅凭 slot_type 推断。

hot_standby_feedback

开启后,Standby 将其最老查询 horizon 反馈给 Primary,Primary 避免清理副本仍可能读取的版本,从而减少 recovery conflict 和查询取消;代价是副本上的一个长查询可以把主库膨胀问题放大。关闭反馈则把成本转化为 Standby 查询取消,由 max_standby_streaming_delay 等参数控制等待边界。

6.11 VACUUM、WAL 与复制延迟

“VACUUM 只是删垃圾,不产生 WAL”是错误的。普通 VACUUM 可能为以下操作写 WAL:

  • Heap Page pruning/freeze 与可见性状态变化;
  • Visibility Map 状态更新;FSM 本身通常不依赖 WAL 持久化,可在恢复后重建;
  • 索引死项清理和页面回收;
  • Relation 尾部截断;
  • Checkpoint 后首次修改页面可能触发 Full Page Image。

VACUUM 还会弄脏数据页,之后由后台写出或 Checkpoint 刷盘。因此大规模维护会与前台争用 I/O,增加 WAL 归档量,并让物理副本承受重放 CPU/I/O。副本上的查询可能与 Heap 清理、VM bit 变化或锁发生 recovery conflict;开启 feedback 能减少取消,却可能反向阻塞主库回收。

在逻辑复制中,VACUUM 本身不是业务 DML,不会作为逐行删除发送;Publisher 与 Subscriber 各自负责本地 VACUUM、冻结和膨胀治理。物理复制则重放主库相关 WAL,空间与维护状态更紧密地跟随主库。

6.12 ANALYZE 与 VACUUM 的边界

VACUUM (ANALYZE) 只是连续执行两类任务:VACUUM 管理物理版本/冻结,ANALYZE 采样并更新 Planner 统计。它们触发公式不同:大量 INSERT 可能急需 ANALYZE 但几乎没有 Dead Tuple;频繁 DELETE 可能同时需要两者。

分区表要特别注意:各叶子分区有自己的 Autovacuum/Autoanalyze,但仅由分区变化未必自动分析分区父表,涉及父表统计的查询应制定手工 ANALYZE 策略。[PG18] VACUUM/ANALYZE 默认处理继承子表;使用 ONLY 可恢复只处理目标 Relation 的行为,升级脚本必须核对维护范围。

7. 内部数据结构和状态

对象/状态本章相关字段或结构VACUUM 作用主要观测入口
Heap Tuple Headerxminxmaxctid、infomask、HOT 标志判断 LIVE/RECENTLY_DEAD/DEAD,Freeze,修剪 HOT 链pageinspect、源码、实验
Line PointerNORMAL/REDIRECT/DEAD/UNUSEDprune 后重定向或释放 offsetheap_page_items()
Heap PagePage Header、item array、Tuple 区prune、压实、冻结、设置可见性pgstattuple、Buffers
Index Tuplekey + Heap TID删除对 Dead Heap Tuple 的引用;页面供复用pgstatindex、索引大小
Visibility Mapall-visible/all-frozen bitVACUUM 验证后设置,DML 清除pg_visibility 扩展、Heap Fetches
Free Space MapPage free-space treeVACUUM/插入更新估算可用空间pg_freespacemap 扩展
Snapshot/ProcArrayxmin/xmax/xip、Backend xmin决定旧版本是否可移除pg_stat_activity.backend_xmin
Global visibility state全局 OldestXmin/可见性边界将 Recently Dead 转为可删 DeadVACUUM VERBOSE、源码
Dead TID Store待索引清理的 (block, offset) 集合分批驱动 index/heap vacuumpg_stat_progress_vacuum
Buffer Descriptorpin、usage count、dirty、content lock页面扫描、清理与脏写pg_stat_io、wait events
Memory ContextVacuum/Index bulk-delete 内存受维护内存与索引实现影响Backend memory 观测/日志
WAL Record/LSNHeap freeze/clean、VM、Index、truncate支持崩溃恢复与物理复制pg_stat_wal、LSN diff
pg_classreltuplesrelpagesrelallvisiblerelallfrozenrelfrozenxidrelminmxid阈值估算、VM/Frozen 汇总系统目录
pg_databasedatfrozenxiddatminmxid数据库级环绕下界age()mxid_age()
Table Statsn_dead_tup、维护时间/次数、DML counters调度依据和趋势观测pg_stat_all_tables
Autovacuum WorkerPID、当前 Relation、phase执行普通/反环绕维护pg_stat_activity、progress/log

锁方面,普通 VACUUM 使用允许正常 SELECT/INSERT/UPDATE/DELETE 的较弱表锁,但会与部分 DDL、VACUUM FULLCLUSTER 等冲突;它还需短时页面级锁和 Buffer Cleanup Lock。尾部截断阶段可能尝试更强锁。VACUUM FULL 从开始到结束需要 ACCESS EXCLUSIVE

8. 场景和选型决策

业务场景推荐方案不推荐方案原因性能代价并发代价一致性代价高可用代价运维复杂度
高频更新、文件大小稳定、空间可复用调低该表触发阈值;普通 VACUUM;优化 HOT/fillfactor定期 FULL稳态 bloat 不等于必须缩文件持续可控 I/O/WAL低到中无业务语义变化可控复制负载
超大表默认比例触发过晚按变更速率与垃圾窗口设置每表 threshold/scale/max cap [PG18]只增加 Worker调度晚比 Worker 少更根本更频繁但每次更小平滑 WAL 更利于副本
只读/append-only 表需要冻结插入触发 VACUUM;装载后 VACUUM (FREEZE, ANALYZE);分区封存永久禁用 Autovacuum无 UPDATE 仍需 VM/Freeze一次顺序扫描与 WAL产生维护 WAL低到中
Heap 已严重膨胀且必须返还磁盘维护窗 VACUUM FULL,或在线 pg_repack/分区替换期待普通 VACUUM 缩到最小中间空页不能直接截掉高 CPU/I/O/WAL/额外盘FULL 高;repack 中交换前需严格验证可能造成大复制延迟
仅 B-tree 索引膨胀REINDEX CONCURRENTLY,先确认索引确有问题VACUUM FULL 全表目标只在索引双扫描、额外盘/WAL低到中,最终锁构建期间需唯一性验证重放压力
接近 XID 环绕清除 horizon blocker;让 anti-wrap vacuum 完成;逐表普通 VACUUMFULL、并发大量重写、关闭 Autovacuum安全目标是尽快推进 freeze 下界高但必要可能挤压前台防止可见性灾难主从都须监控年龄/lag
Standby 长查询频繁取消有界 feedback + 查询超时/只读工作负载隔离无期限 hot_standby_feedback=on在副本取消与主库 bloat 间取舍主库空间/扫描成本可能上升低锁、高 horizon 影响读一致性窗口更长lag 与 failover 风险上升
逻辑 Slot 离线恢复消费者、推进/重建 Slot,分别看 WAL 与 catalog_xmin只扩 pg_wal 磁盘Slot 可能同时阻塞 WAL 与目录清理存储/WAL/目录膨胀间接需评估丢失 CDC 位点故障切换与 Slot 连续性复杂
写峰值导致 Vacuum 追不上Admission Control、削峰、增加可承受 Worker/I/O、每表调度无限 goroutine/连接与 Worker垃圾生成速率必须低于清理能力限制峰值换稳定 P99降低锁/WAL 排队业务需幂等与背压减少 lag/归档失速中到高

9. 高性能分析

9.1 成本模型

一次 VACUUM 的近似资源量可分解为:

Heap 扫描字节
+ 需要清理的索引扫描/随机访问
+ Heap/VM/FSM 脏写
+ WAL 与 Full Page Image
+ OS 回写与 Checkpoint
+ Replica replay

其中 CPU 用于 Tuple 可见性判断、HOT 链处理、索引回调、WAL 组装和压缩;内存主要用于 Dead TID、索引 bulk-delete 状态和 Buffer descriptors;网络通常不是主库本地瓶颈,但物理复制、归档上传、监控采集和云存储路径会把 WAL 量转化为网络成本。

9.2 shared buffers、OS Page Cache 与随机/顺序 I/O

Heap 扫描偏顺序,但索引清理可能按 TID/索引结构产生更离散访问。较小 Buffer Access Strategy 可避免扫描污染 shared buffers,却不能保证 OS Cache 不被推动。判断瓶颈应结合:

  • pg_stat_io 的 reads、writes、extends、evictions、reuse、read/write time;
  • 设备队列深度、吞吐、P95/P99 时延与 fsync;
  • VACUUM progress 的扫描速度与 delay_time
  • 前台查询 shared/local/temp blocks 与 wait events;
  • Checkpoint 写入速率与 buffers_backend 等对应版本指标。

9.3 [PG18] AIO 的作用边界

PG18 的异步 I/O 子系统允许符合条件的读取并发提交,io_method 可选择 worker、io_uring(构建支持时)或同步执行,effective_io_concurrency/maintenance_io_concurrency 默认基线也发生变化。VACUUM 和其他维护扫描可从更深的 I/O 队列、合并读取和较少等待中受益,但 AIO 不会消除:

  • Tuple 可见性与索引清理 CPU;
  • 脏页写回、WAL flush 和 Checkpoint;
  • Buffer 锁、Relation 锁和旧快照地平线;
  • 存储本身的吞吐/时延上限。

在低时延本地 NVMe、网络块存储、旋转盘和云突发型卷上,最优并发深度不同。必须用维护窗口中的真实前台 SLO 验证,而不是因为 AIO 存在就盲目提高并发。

9.4 WAL、Checkpoint 与延迟分位数

大批冻结、索引清理或重写会增加 WAL,可能触发更频繁 Checkpoint 或让 Checkpoint 写入集中。平均 TPS 看似不变时,前台 P99 仍可能因以下原因恶化:

  • Backend 找不到 clean buffer,被迫自己写脏页;
  • 存储队列被维护 I/O 占满;
  • Checkpoint 后 FPI 增多,WAL/网络突发;
  • Autovacuum Worker 与前台争用 CPU;
  • Replica lag 导致只读流量回切主库或同步复制提交等待。

9.5 读放大、写放大、空间放大

  • 读放大:Seq Scan 读取低密度 Heap Page;索引层级变深;Index Only Scan 出现 Heap Fetch;缓存命中率下降。
  • 写放大:每次非 HOT UPDATE 写新 Heap Tuple、多个 Index Tuple、WAL,随后 VACUUM 再写清理记录;重写又复制全部存活数据。
  • 空间放大:Heap dead/free space、Index 低密度、TOAST 旧版本、WAL/Slot 保留、重写临时副本同时存在。

真正目标不是把 dead_pct 永远压到 0,而是让垃圾生成与回收达到稳定平衡,并满足查询延迟、磁盘增长和复制 SLO。

9.6 参数推导而非固定答案

对某表先测:

R = 峰值不可见版本生成速率(tuples/s)
W = 允许的最大垃圾窗口(s)
D = R × W = 目标触发 dead tuples
N = reltuples

再选择 threshold + scale_factor × N ≈ D,并确保单次 VACUUM 在下一窗口前能完成。还要核对 Worker 排队、I/O 容量和高峰重叠。对 24×7 超大表,较小 scale factor + 合理 base threshold + [PG18] max threshold 往往比单一比例更可预测,但具体值必须由 R、W、行宽、索引数和硬件决定。

Temporary File 通常不是 VACUUM 主路径,但 VACUUM FULL/在线重写周边的索引构建、排序、诊断查询和外部工具可能使用临时空间;不要只看 Relation 增量而忽略临时目录、WAL 和归档共同的峰值磁盘。

10. 高并发分析

10.1 数据库并发与应用并发不是同一个数

goroutine 数 >= 排队请求数 + 活跃请求数
连接池连接数 >= 空闲连接 + 执行 SQL/事务的连接
数据库活跃查询数 <= 已获取连接数
TPS = 单位时间完成事务,不等于上述任一并发数

无限 goroutine 会在 pgxpool 获取连接处排队;若每个请求又持有长事务,数据库同时承受连接占用、Snapshot horizon、锁、WAL 和 Autovacuum 落后。健康检查器和业务代码都应有有界并发、获取/查询超时和背压。

10.2 MVCC、锁与 Vacuum 的交互

普通 VACUUM 不会像 FULL 一样阻塞日常 DML,但它会:

  • 与需要更强 Relation Lock 的 DDL 互斥;
  • 在 Buffer Cleanup Lock 上短时竞争,繁忙页面可能暂时无法 prune;
  • 清理索引时增加索引页面 latch/I/O 压力;
  • 尾部截断时尝试较强锁,造成短时队列;
  • 被长事务“逻辑阻塞”——不是等待锁,而是无法删除数据。

最后一种最容易误判:pg_stat_progress_vacuum 可能持续前进且无 wait_event,但大量 Tuple “dead but not yet removable”。

10.3 热点行、热点索引页与 MultiXact

热点行上频繁 Tuple Lock 会创建 MultiXact;频繁更新又持续产生版本。递增键索引的右侧页可能成为插入热点,VACUUM/索引清理与前台修改共享页面级同步。优化手段包括分片计数器、事件追加后异步聚合、缩短事务、避免不必要索引、提高 HOT 比例和按业务键分散写入,但每种方案都改变一致性与读路径。

10.4 阻塞队列、死锁与重试风暴

VACUUM 本身通常不是死锁主角,但 VACUUM FULL/REINDEX/DDL 的强锁会进入锁队列。一个长 ACCESS SHARE 事务挡住待执行 FULL 后,后续与 FULL 冲突的请求可能排在其后形成 lock convoy。应用看到超时后若无上限地重试,会进一步占连接、写日志和加剧队列。

维护操作应设置有意识的 lock_timeout,失败后退出并重新排期,而不是无限等待;业务重试只针对明确 SQLSTATE,且必须有指数退避、抖动和全局 admission control。

10.5 事务边界、外部调用与幂等

最危险的模式之一是:

BEGIN
SELECT/UPDATE
调用慢 RPC、等待用户、发送大文件
COMMIT

它把不受数据库控制的延迟纳入 Snapshot 和锁生命周期。应先完成可移出的外部工作,再开启最短事务;确需跨系统一致性时使用 Outbox、Saga 或可靠 2PC 协调器,并为超时/重试设计幂等键。连接池层面必须监控 Acquire Duration、Acquired/Idle/Total Conns,避免把数据库慢误诊为“需要更多连接”。

11. 高可用分析

11.1 RPO/RTO 的关系

VACUUM 不直接改变已提交事务的耐久性,所以对 RPO 多为间接影响;但它产生的 WAL 可压垮归档或复制链路,导致可用恢复点落后。对 RTO 的影响更直接:膨胀会扩大备份、恢复、缓存预热、校验和故障切换后的工作集;接近环绕时,故障切到同样老化的副本不能消除风险。

11.2 物理复制、同步复制与只读副本

  • 异步复制:维护 WAL 突发会扩大 replay lag,故障切换可能增加数据损失窗口。
  • 同步复制:若同步 Standby 重放/接收或存储受压,提交延迟可能上升;具体取决于 synchronous_commit 等待级别。
  • Hot Standby:清理 WAL 与长查询冲突时,要在取消查询、延迟重放和 feedback 导致主库 bloat 之间选择。
  • Planned Switchover:切换前检查 XID/MultiXact age、slot、WAL lag、正在进行的 FULL/repack/reindex 和归档余量,避免把维护风暴带入角色切换。
  • Unplanned Failover:新主库接管后重新确认 Autovacuum Worker、每表参数、Slot/订阅和只读流量路由;旧连接可能仍指向旧主,必须依赖 fencing 防止双写。

11.3 Backup、PITR 与数据校验

物理备份会复制膨胀后的文件,导致备份窗口、对象存储成本和恢复时间增加。普通 VACUUM 回收内部空间后,已有物理文件仍可能保持大;只有重写/截断才明显缩小后续备份。FULL、REINDEX 和 repack 的 WAL 必须能被归档链路及时吸收,否则 PITR 的 RPO/RTO 反而恶化。

修复后不能只看表变小,还应执行:

  • 备份成功与恢复演练;
  • 物理副本追平、时间线与 Slot 状态核验;
  • amcheck/校验策略;
  • 核心查询计划与行数估算;
  • 应用重连和事务结果不确定场景验证。

11.4 逻辑复制与 CDC

Publisher 和 Subscriber 的 Tuple 生命周期独立,二者都需要本地 Autovacuum。逻辑 Slot 的 catalog_xmin 可阻塞 Publisher 系统目录清理;失联消费者还可能通过 restart_lsn 堆积 WAL。执行在线重建、分区交换或扩展工具前,应验证 CDC 对 DDL、OID/relfilenode 变化、触发器和复制身份的处理,不得假定“在线”就等于“对所有复制拓扑透明”。

11.5 Failback、脑裂与 Fencing

VACUUM 不是脑裂防护机制。Failover 后若旧 Primary 未被 fencing,两个节点都接受写入,即使各自 VACUUM 正常也无法合并冲突历史。Failback 需要重新建立数据一致性,而不是简单把旧节点切回。维护脚本必须通过角色检测、租约或编排系统确认目标是当前合法 Primary,避免在错误节点执行 FULL/repack 或删除 Slot。

12. 三维影响矩阵

维度相关度核心收益主要风险关键指标
高性能控制 Heap/Index/VM 退化,维持计划与缓存效率维护 I/O、WAL、Checkpoint、缓存污染、重写峰值dead 生成/清理速率、Relation/Index size、Heap Fetches、Buffers、WAL/s、P95/P99
高并发MVCC 下无阻塞回收旧版本,减少页/索引压力长事务固定 horizon;FULL/DDL 锁队列;Worker 与前台竞争backend_xmin、xact age、wait events、blocked count、pool acquire、MultiXact age
高可用中到高降低备份/恢复工作集,避免环绕停写复制冲突/lag、Slot 保留、归档失速、切换时维护放大replay lag、retained WAL、xmin/catalog_xmin、archive failures、XID/MXID time-to-limit

13. PostgreSQL 14—18 重要差异

版本与本章相关的重要变化升级/兼容注意
PG14Vacuum failsafe;INDEX_CLEANUP=AUTO;更积极的 B-tree dead item 复用;PROCESS_TOAST;更强环绕保护14 已具备本章主要安全模型;不要把后续版本进度字段直接用于 14
PG15VACUUM VERBOSE/Autovacuum 日志信息增强;统计系统改为共享内存;更积极推进 frozen/MultiXact 下界统计刷新语义和日志基线与旧版本不同;升级后重建监控基线
PG16vacuum_buffer_usage_limitVACUUM/ANALYZE BUFFER_USAGE_LIMIT;BRIN 索引列更新在条件满足时可保持 HOT运维脚本可显式限制 Buffer ring,但跨版本执行前检查语法
PG17VACUUM 新内存管理;进度字段改为 byte 与 item-id:max_dead_tuple_bytesdead_tuple_bytesnum_dead_item_ids;移除 old_snapshot_threshold旧监控字段名会报错;升级扩展/仪表盘必须同步修改
PG18AIO 可加速部分维护 I/O;autovacuum_worker_slotsautovacuum_vacuum_max_thresholdvacuum_truncate GUC;eager freezing 及 vacuum_max_eager_freeze_failure_rate;表统计新增累计维护耗时;progress/VERBOSE 可报告 cost delay;pg_signal_autovacuum_worker 角色;VACUUM/ANALYZE 默认递归处理继承子表Worker 槽位是启动期容量,autovacuum_max_workers 可在槽位范围内运行时调整;升级后检查维护作用域、告警字段和阈值 cap

PG18 eager freezing 会尝试冻结部分已 all-visible 的页面,以减少未来 aggressive scan,但受失败率参数控制。它改变维护时机,不改变“所有可见性判断必须安全”的原则。pg_signal_autovacuum_worker 只降低管理权限门槛,不代表可以随意终止 anti-wraparound Worker。

14. 实验

三个实验都会制造额外磁盘、WAL 和 I/O。只能在隔离测试集群执行;先确认 tablespace、pg_wal、归档和副本空间。所有耗时必须在你的环境实测,禁止把本章示例规模或结果当成固定基准。

14.1 实验一:高频 UPDATE 制造 Heap 与 Index Bloat

14.1.1 实验目标

验证以下结论:

  1. 非 HOT UPDATE 会同时产生旧 Heap Tuple 和新 Index Tuple;
  2. n_dead_tup 是估算,pgstattuple_approx/pgstattuple 是更直接但成本更高的扫描;
  3. 普通 VACUUM 清理后,dead tuple 显著下降,但 Heap/Index 文件通常不会按相同比例缩小;
  4. 清理后空间可由后续写入复用,增长斜率比单次文件大小更能说明问题;
  5. VM 恢复后,Index Only Scan 的 Heap Fetches 可能下降。

14.1.2 版本、扩展与实验记录

  • PostgreSQL:14—18;建议 PG18;
  • 必要扩展:无;精确/近似 bloat 观测可选 pgstattuple
  • 可选:pg_stat_statements 与系统 I/O 监控;
  • 记录:SELECT version()、相关 SHOW、CPU/内存/存储、缓存冷热状态、并发数、行数、平均行宽、测试持续时间、前台 P50/P95/P99、Buffers、WAL bytes、CPU、I/O、Wait Event。
CREATE SCHEMA IF NOT EXISTS vacuum_lab;
CREATE EXTENSION IF NOT EXISTS pgstattuple;

DROP TABLE IF EXISTS vacuum_lab.churn;
CREATE TABLE vacuum_lab.churn (
    id          bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    tenant_id   integer NOT NULL,
    status      integer NOT NULL,
    payload     text NOT NULL,
    updated_at  timestamptz NOT NULL DEFAULT clock_timestamp()
) WITH (
    autovacuum_enabled = false, -- 仅实验表;实验结束必须恢复/删除
    fillfactor = 100
);

CREATE INDEX churn_status_idx
    ON vacuum_lab.churn (tenant_id, status, id);

INSERT INTO vacuum_lab.churn (tenant_id, status, payload)
SELECT
    1 + (g % 100),
    g % 20,
    repeat(md5(g::text), 6)
FROM generate_series(1, 300000) AS g;

ANALYZE vacuum_lab.churn;
CHECKPOINT;  -- 仅隔离环境;用于记录明确的实验起点,不是生产调优手段

基线:

SELECT
    pg_size_pretty(pg_relation_size('vacuum_lab.churn')) AS heap,
    pg_size_pretty(pg_indexes_size('vacuum_lab.churn')) AS indexes,
    pg_size_pretty(pg_total_relation_size('vacuum_lab.churn')) AS total;

SELECT * FROM pgstattuple_approx('vacuum_lab.churn'::regclass);
SELECT * FROM pgstatindex('vacuum_lab.churn_status_idx'::regclass);

EXPLAIN (ANALYZE, BUFFERS, WAL, SETTINGS, VERBOSE, SUMMARY)
SELECT id, status
FROM vacuum_lab.churn
WHERE tenant_id = 42 AND status = 7
ORDER BY id
LIMIT 500;

14.1.3 Session A:制造版本链和索引垃圾

每条 UPDATE 在 psql autocommit 模式下单独提交。必须修改索引列 status,从而刻意阻止 HOT:

UPDATE vacuum_lab.churn
SET status = (status + 1) % 20,
    payload = md5(payload || clock_timestamp()::text),
    updated_at = clock_timestamp();

-- 重复执行 6—10 轮;每轮都是一次独立提交。
-- 可在每轮后记录 pg_current_wal_lsn(),不要把轮数当作生产建议。

若要用 EXPLAIN ANALYZE 分析 UPDATE,它会真正执行:

BEGIN;
EXPLAIN (ANALYZE, BUFFERS, WAL, SETTINGS, VERBOSE, SUMMARY)
UPDATE vacuum_lab.churn
SET status = (status + 1) % 20,
    updated_at = clock_timestamp()
WHERE id BETWEEN 1 AND 10000;
ROLLBACK;

本实验没有触发器或外部副作用,但一般情况下 ROLLBACK 不保证撤销 Sequence 消耗、外部调用或非事务性副作用。

14.1.4 Session B:并行观测

在 A 每轮提交后执行:

SELECT pg_stat_clear_snapshot();

SELECT
    relid::regclass,
    n_live_tup,
    n_dead_tup,
    n_tup_upd,
    n_tup_hot_upd,
    round(100.0 * n_tup_hot_upd / NULLIF(n_tup_upd, 0), 2) AS hot_pct,
    last_vacuum,
    last_autovacuum
FROM pg_stat_user_tables
WHERE relid = 'vacuum_lab.churn'::regclass;

SELECT
    pg_relation_size('vacuum_lab.churn') AS heap_bytes,
    pg_indexes_size('vacuum_lab.churn') AS index_bytes,
    pg_current_wal_lsn() AS current_lsn;

SELECT * FROM pgstattuple_approx('vacuum_lab.churn'::regclass);

SELECT pid, state, wait_event_type, wait_event, left(query, 120)
FROM pg_stat_activity
WHERE datname = current_database()
ORDER BY pid;

Session B 的查询不应等待 A 的已提交轮次;若在 A 的单次大 UPDATE 期间执行,统计可能尚未包含未提交结果,但普通观测不会因行锁而阻塞。

14.1.5 Session C:普通 VACUUM

A 停止并确认最后一轮提交后:

VACUUM (VERBOSE, ANALYZE) vacuum_lab.churn;

另一个会话观察:

SELECT *
FROM pg_stat_progress_vacuum
WHERE relid = 'vacuum_lab.churn'::regclass;

预期没有失败或长锁等待;若同表已有 VACUUM、DDL 或表重写,C 可能等待 Relation Lock,应通过 pg_blocking_pids(pid) 定位而不是盲目终止。

14.1.6 明确时间线

t0 建表、装载、ANALYZE、记录基线
 t1 A: UPDATE 第 1 轮并 COMMIT
 t2 B: 记录 dead estimate、size、WAL、等待
 t3 A/B 重复若干轮
 t4 A: 最后一轮 COMMIT;停止写入
 t5 C: 普通 VACUUM ANALYZE;正常情况下不等待业务锁
 t6 B: 再次记录 size、pgstattuple、计划与 Heap Fetches
 t7 A: 再执行若干轮相同 UPDATE,验证旧空间是否复用
  • 等待步骤:正常情况下无;只可能出现资源争用或同类维护/DDL 锁等待。
  • 失败步骤:正常情况下无;磁盘不足、statement timeout 或权限错误属于环境失败。
  • 提交步骤:A 每轮 UPDATE 自动提交;C 的 VACUUM 独立执行,不能包在事务块。

14.1.7 预期结果与解释

  1. n_tup_hot_upd 接近 0,因为更新了索引键;
  2. n_dead_tup、Heap/Index size 和 WAL 增长,但统计值与精确扫描不完全相同;
  3. 普通 VACUUM 后 n_dead_tup 估算和 dead_tuple_percent 下降;
  4. pg_relation_size/pg_indexes_size 通常不会同比缩小,说明空间已内部复用而非返还 OS;
  5. 后续同等 UPDATE 可能更多复用 Heap/Index free space,文件增长速度低于第一次;
  6. VACUUM 设置更多 all-visible bit 后,读查询 Heap Fetches 可能下降;随后 UPDATE 会再次清除相关 VM bit。

结果表必须由你填写:

阶段Heap bytesIndex bytesDead tuple estimatepgstattuple dead/free %WAL 增量P50/P95/P99CPU读/写 IOPS主要 Wait
基线
UPDATE 后
VACUUM 后
再次 UPDATE 后

14.1.8 清理与生产安全警告

DROP TABLE IF EXISTS vacuum_lab.churn;

生产环境不得用“关闭 Autovacuum + 全表反复 UPDATE”做演练;不要在高峰对大表执行精确 pgstattupleCHECKPOINT 或无条件 UPDATE。先在克隆数据、隔离副本或专用压测环境评估 WAL、磁盘和归档上限。


14.2 实验二:长事务让旧版本停留在 Recently Dead

14.2.1 实验目标

复现一个没有锁阻塞、却能让 VACUUM 无法回收旧版本的长事务;识别 backend_xminxact_startidle in transaction 和 VACUUM VERBOSE 中的不可移除版本。

14.2.2 版本、扩展与准备

  • PostgreSQL:14—18;
  • 必要扩展:无;pgstattuple 可选;
  • 三个独立 psql 会话 A/B/C。
DROP TABLE IF EXISTS vacuum_lab.old_snapshot;
CREATE TABLE vacuum_lab.old_snapshot (
    id bigint PRIMARY KEY,
    payload text NOT NULL
) WITH (autovacuum_enabled = false);

INSERT INTO vacuum_lab.old_snapshot
SELECT g, repeat(md5(g::text), 4)
FROM generate_series(1, 200000) AS g;

ANALYZE vacuum_lab.old_snapshot;

14.2.3 Session A:固定快照后变成 idle in transaction

BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT count(*) FROM vacuum_lab.old_snapshot;
SELECT txid_current_if_assigned(), pg_backend_pid();

-- 此后不要执行 COMMIT;让会话停在提示符。

A 的第一条 SELECT 建立事务级快照。命令完成后,pg_stat_activity.state 变为 idle in transaction,但快照仍保留。

14.2.4 Session B:删除并提交

BEGIN;
DELETE FROM vacuum_lab.old_snapshot
WHERE id <= 150000;
COMMIT;

B 的 DELETE 取得行锁并修改 xmax,但 A 只读取且不持有冲突行锁,所以 B 正常提交。对新事务而言这些行已删除;对 A 的旧快照而言仍可见。

验证:

-- Session B 或新会话:
SELECT count(*) FROM vacuum_lab.old_snapshot; -- 预期 50000

-- Session A:
SELECT count(*) FROM vacuum_lab.old_snapshot; -- 仍预期 200000

14.2.5 Session C:VACUUM 与诊断

SELECT
    pid, state, xact_start,
    clock_timestamp() - xact_start AS age,
    backend_xmin,
    age(backend_xmin) AS xmin_age,
    wait_event_type, wait_event,
    left(query, 160)
FROM pg_stat_activity
WHERE datname = current_database()
ORDER BY xact_start NULLS LAST;

VACUUM (VERBOSE, ANALYZE) vacuum_lab.old_snapshot;

VACUUM 正常完成,并不会等待 A;但 VERBOSE/日志应显示存在 dead but not yet removable 的版本或相应不可回收计数。继续观测:

SELECT
    n_live_tup, n_dead_tup,
    last_vacuum, vacuum_count,
    pg_relation_size('vacuum_lab.old_snapshot') AS heap_bytes
FROM pg_stat_user_tables
WHERE relid = 'vacuum_lab.old_snapshot'::regclass;

SELECT * FROM pgstattuple_approx('vacuum_lab.old_snapshot'::regclass);

n_dead_tup 与扩展输出受统计/扫描语义影响,不要用单一数字证明 “Recently Dead”;最直接证据是 A 的旧快照仍能读到 200000 行、A 的 backend_xmin、以及 VACUUM 不能移除这些版本。

14.2.6 释放地平线并再次清理

Session A:

COMMIT;

Session C:

VACUUM (VERBOSE, ANALYZE) vacuum_lab.old_snapshot;

再次检查统计和扩展;旧版本现在可移除。Heap 文件可能仍保持原大小,但内部 free space 增加。

14.2.7 明确时间线

t0 准备 200000 行
 t1 A: BEGIN REPEATABLE READ;SELECT 建立 S1;保持 idle in transaction
 t2 B: DELETE 150000 行;COMMIT 成功
 t3 A: 仍看到 200000;新会话看到 50000
 t4 C: VACUUM 完成但不能移除 A 仍可见的旧版本;不等待、不失败
 t5 A: COMMIT,释放 Snapshot/horizon
 t6 C: 第二次 VACUUM,旧版本转为可移除并清理
  • 等待步骤:无预期锁等待;A 是逻辑地平线阻塞者。
  • 失败步骤:无预期失败。若配置了 idle_in_transaction_session_timeout,A 可能被服务器终止,这是保护机制。
  • 提交步骤:B 在 t2 提交;A 在 t5 提交;VACUUM 自身不能置于显式事务块。

14.2.8 结果解释与生产警告

这个实验说明“VACUUM 没有等待”不代表“VACUUM 清干净了”。生产中应同时告警长事务持续时间、backend_xmin 年龄和表维护债务;仅告警锁等待会漏掉这种故障。终止生产会话前要确认事务所有者、业务幂等性和回滚成本;Prepared Transaction 不能用 pg_terminate_backend 解决,必须由 2PC 协调器决议。

DROP TABLE IF EXISTS vacuum_lab.old_snapshot;

14.3 实验三:比较 VACUUM、VACUUM FULL、REINDEX 与 pg_repack

14.3.1 实验目标

对同一类膨胀分别验证:

  • 普通 VACUUM:在线清理和内部复用,不保证缩文件;
  • REINDEX CONCURRENTLY:只紧凑索引,不处理 Heap;
  • VACUUM FULL:重写 Heap/索引并返还空间,但需强锁;
  • pg_repack:通过副本与变更日志在线重写,多数时间允许 DML,但不是零锁、零 WAL 或零额外空间。

14.3.2 版本、扩展与准备

  • PostgreSQL:14—18;
  • pgstattuple 可选;
  • pg_repack 部分需要服务器扩展和匹配版本的客户端二进制;当前官方 1.5 系列支持 PG14—18,执行前仍应核对部署版本;
  • 建议至少三会话,并准备系统层磁盘/WAL/复制监控。
DROP TABLE IF EXISTS vacuum_lab.method_compare;
CREATE TABLE vacuum_lab.method_compare (
    id bigint PRIMARY KEY,
    k integer NOT NULL,
    payload text NOT NULL,
    updated_at timestamptz NOT NULL
) WITH (autovacuum_enabled = false, fillfactor = 100);

CREATE INDEX method_compare_k_idx
    ON vacuum_lab.method_compare (k, id);

INSERT INTO vacuum_lab.method_compare
SELECT g, g % 1000, repeat(md5(g::text), 8), clock_timestamp()
FROM generate_series(1, 1000000) AS g;

ANALYZE vacuum_lab.method_compare;

制造非 HOT bloat;每条单独提交,按磁盘容量减少规模:

UPDATE vacuum_lab.method_compare
SET k = (k + 1) % 1000,
    payload = md5(payload || clock_timestamp()::text),
    updated_at = clock_timestamp();
-- 重复 4—8 轮

统一测量:

SELECT
    pg_relation_size('vacuum_lab.method_compare') AS heap_bytes,
    pg_indexes_size('vacuum_lab.method_compare') AS index_bytes,
    pg_total_relation_size('vacuum_lab.method_compare') AS total_bytes;

SELECT * FROM pgstattuple_approx('vacuum_lab.method_compare'::regclass);
SELECT * FROM pgstatindex('vacuum_lab.method_compare_k_idx'::regclass);

14.3.3 方法 A:普通 VACUUM

Session B:

VACUUM (VERBOSE, ANALYZE) vacuum_lab.method_compare;

Session C 同时运行普通 SELECT/UPDATE,预期可以继续;记录前台 P95/P99、I/O、WAL 和 Wait Event。完成后复测:dead tuple 下降,但 Heap/Index 文件通常不会按比例缩小。

14.3.4 方法 B:REINDEX CONCURRENTLY

再次执行数轮非 HOT UPDATE 制造索引垃圾,然后:

REINDEX INDEX CONCURRENTLY vacuum_lab.method_compare_k_idx;

另一个会话观察:

SELECT * FROM pg_stat_progress_create_index;

SELECT
    indexrelid::regclass,
    indisvalid,
    indisready,
    indislive
FROM pg_index
WHERE indrelid = 'vacuum_lab.method_compare'::regclass;

预期业务 DML可继续,但构建消耗双份索引空间、扫描 Heap/Index 并产生 WAL;旧快照可能让某阶段等待。完成后索引文件可能变小,Heap 大小不变。失败时先检查 invalid index,再按对象名和依赖安全删除,不要自动 DROP INDEX 猜测。

14.3.5 方法 C:VACUUM FULL 的锁行为

先再次制造 bloat。Session A 持有普通读锁:

BEGIN;
SELECT count(*) FROM vacuum_lab.method_compare WHERE k = 7;
SELECT pg_backend_pid();
-- 保持事务不提交,可另执行 SELECT pg_sleep(60);

Session B:

SET lock_timeout = '3s';
VACUUM (FULL, VERBOSE, ANALYZE) vacuum_lab.method_compare;

B 预期在获取 ACCESS EXCLUSIVE 时等待,并因 lock_timeout 失败。Session C 定位阻塞链:

SELECT
    a.pid,
    a.state,
    a.wait_event_type,
    a.wait_event,
    pg_blocking_pids(a.pid) AS blockers,
    left(a.query, 180) AS query
FROM pg_stat_activity AS a
WHERE a.datname = current_database();

SELECT
    l.pid, l.locktype, l.mode, l.granted,
    l.relation::regclass AS relation
FROM pg_locks AS l
WHERE l.relation = 'vacuum_lab.method_compare'::regclass
ORDER BY l.granted DESC, l.pid;

Session A:

COMMIT;

Session B 重新执行前先评估维护窗并重置超时:

RESET lock_timeout;
VACUUM (FULL, VERBOSE, ANALYZE) vacuum_lab.method_compare;

FULL 执行期间,Session C 的普通 SELECT/UPDATE 将等待 ACCESS EXCLUSIVE;表规模足够大时可在 pg_stat_progress_cluster 观察重写。完成后 Heap 与索引通常明显缩小,但会有高 I/O、额外磁盘和 WAL。

14.3.6 方法 D:pg_repack 在线重写

再次制造 bloat。先检查前置条件:

CREATE EXTENSION IF NOT EXISTS pg_repack;

SELECT conname, contype
FROM pg_constraint
WHERE conrelid = 'vacuum_lab.method_compare'::regclass;

SELECT pg_size_pretty(pg_total_relation_size('vacuum_lab.method_compare'));

系统 shell 中先 dry run。连接串由 libpq 环境或安全 secret 注入,不写入脚本/日志:

pg_repack \
  --dbname="$DATABASE_URL" \
  --table=vacuum_lab.method_compare \
  --dry-run

实际执行建议在首次演练中禁止工具主动取消/终止冲突会话:

pg_repack \
  --dbname="$DATABASE_URL" \
  --table=vacuum_lab.method_compare \
  --no-order \
  --wait-timeout=5 \
  --no-kill-backend

Session C 同时观察业务 DML、锁、repack schema 临时对象、WAL、磁盘和复制延迟。预期多数阶段 INSERT/UPDATE/DELETE 可继续;初始设置和最终 swap 仍需短暂 ACCESS EXCLUSIVE,全表 repack 期间持有 SHARE UPDATE EXCLUSIVE 以禁止目标表 DDL。高写入时日志表可能增长,工具可能追不上。

14.3.7 明确时间线、等待与失败

t0 制造共同 bloat、记录基线
 t1 方法 A: VACUUM;前台继续;无预期失败
 t2 重新制造 bloat
 t3 方法 B: REINDEX CONCURRENTLY;DML 继续,某阶段可能等待旧快照;失败可留 invalid index
 t4 重新制造 bloat
 t5 A 持普通事务;B 执行 FULL,等待并被 lock_timeout 取消(预期失败)
 t6 A COMMIT;B 在维护窗执行 FULL;C 的读写等待 FULL(预期等待)
 t7 重新制造 bloat
 t8 pg_repack dry-run;再实际执行;初始/最终锁可能等待,--no-kill-backend 下超时则跳过而不杀业务
 t9 对比空间、WAL、耗时、P95/P99、复制 lag 和锁影响

14.3.8 对比结论与测量表

方法Heap 缩小Index 缩小日常 DML主要锁额外磁盘WAL/副本压力失败恢复复杂度
VACUUM通常否;仅尾部可截断通常不紧凑,只可复用可继续较弱;截断阶段例外中,视冻结/清理量
REINDEX CONCURRENTLY可继续多阶段短锁/等待旧快照至少新索引空间中到高中;可能留 invalid index
VACUUM FULL随表重建全程阻塞ACCESS EXCLUSIVE新表/索引临时空间中;事务性重写但窗口风险高
pg_repack多数阶段可继续开始/交换短 AEL;过程中 SUE官方建议约目标表+索引的额外两倍量级高;外部工具、临时对象、追赶与版本匹配
方法总时长锁等待Heap 前/后Index 前/后WAL 增量主库 P95/P99Replica lag 峰值磁盘峰值
VACUUM
REINDEX CONCURRENTLY
VACUUM FULL
pg_repack

14.3.9 清理与生产警告

DROP TABLE IF EXISTS vacuum_lab.method_compare;
-- 仅确认没有其他对象依赖、也没有残留任务后再决定是否:
-- DROP EXTENSION pg_repack;

生产重写前必须完成:容量峰值计算、归档/副本压测、锁超时策略、长事务清理、CDC/触发器验证、失败清理步骤、回滚/重新调度方案和恢复演练。pg_repack --wait-timeout 默认可能取消乃至终止冲突后端;不理解该行为时必须使用 --no-kill-backend

15. Go + pgx/v5:VACUUM 健康检查器

这个程序是“采集与告警判定”的参考实现,不自动 VACUUM、终止会话、删除 Slot 或执行重写。自动修复会把一次误报直接变成数据可用性事故,应由 Runbook、权限审批和审计系统控制。

设计要点:

  • DATABASE_URL 只从环境变量读取;
  • 所有 SQL 使用 $1$2 参数;对象名来自系统目录,不拼接用户输入;
  • 每个检查都有 context.WithTimeout
  • 固定检查集合使用 semaphore 控制并发,不无限创建 goroutine;
  • 每个 Rows 都显式 Close() 并检查 rows.Err()
  • 使用 errors.As*pgconn.PgError 提取 SQLSTATE;
  • SIGINT/SIGTERM 取消根 Context,等待当次查询服从取消后关闭 Pool;
  • 输出包含 pgxpool 快照,区分数据库健康与连接池排队;
  • 不使用显式长事务,因为跨多个监控查询保持同一 Snapshot 反而会制造本章所讨论的问题;
  • 所有阈值都必须显式注入,程序不提供“万能默认值”。
package main

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"log/slog"
	"os"
	"os/signal"
	"sort"
	"strconv"
	"sync"
	"syscall"
	"time"

	"github.com/jackc/pgx/v5"
	"github.com/jackc/pgx/v5/pgconn"
	"github.com/jackc/pgx/v5/pgxpool"
)

type Policy struct {
	CheckInterval        time.Duration
	QueryTimeout         time.Duration
	LongTxThreshold      time.Duration
	IdleInTxThreshold    time.Duration
	AutovacuumLag        time.Duration
	DeadTupleRatio       float64
	MinTableBytes        int64
	XIDTriggerRatio      float64
	MXIDTriggerRatio     float64
	SlotRetainedWALBytes int64
	MaxConcurrentChecks  int
	DBMaxConns           int32
}

type Finding struct {
	Check    string         `json:"check"`
	Severity string         `json:"severity"`
	Object   string         `json:"object"`
	Message  string         `json:"message"`
	Data     map[string]any `json:"data,omitempty"`
}

type CheckError struct {
	Check    string `json:"check"`
	Message  string `json:"message"`
	SQLState string `json:"sqlstate,omitempty"`
}

type PoolSnapshot struct {
	AcquiredConns int32         `json:"acquired_conns"`
	IdleConns     int32         `json:"idle_conns"`
	TotalConns    int32         `json:"total_conns"`
	MaxConns      int32         `json:"max_conns"`
	AcquireCount  int64         `json:"acquire_count"`
	AcquireTime   time.Duration `json:"acquire_duration"`
}

type Report struct {
	At       time.Time    `json:"at"`
	Findings []Finding    `json:"findings"`
	Errors   []CheckError `json:"errors,omitempty"`
	Pool     PoolSnapshot `json:"pool"`
}

type namedCheck struct {
	name string
	fn   func(context.Context, *pgxpool.Pool, Policy) ([]Finding, error)
}

type checkResult struct {
	name     string
	findings []Finding
	err      error
}

func main() {
	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
	defer stop()

	url := os.Getenv("DATABASE_URL")
	if url == "" {
		slog.Error("DATABASE_URL is required")
		os.Exit(2)
	}

	policy, err := loadPolicy()
	if err != nil {
		slog.Error("invalid health policy", "error", err)
		os.Exit(2)
	}

	cfg, err := pgxpool.ParseConfig(url)
	if err != nil {
		slog.Error("parse DATABASE_URL", "error", err)
		os.Exit(2)
	}
	cfg.MaxConns = policy.DBMaxConns
	cfg.MinConns = 0

	pool, err := pgxpool.NewWithConfig(ctx, cfg)
	if err != nil {
		slog.Error("create pgx pool", "error", errorMessage(err))
		os.Exit(1)
	}
	defer pool.Close()

	pingCtx, cancel := context.WithTimeout(ctx, policy.QueryTimeout)
	err = pool.Ping(pingCtx)
	cancel()
	if err != nil {
		slog.Error("database ping failed", "error", errorMessage(err))
		os.Exit(1)
	}

	checks := []namedCheck{
		{name: "transactions", fn: checkTransactions},
		{name: "prepared_transactions", fn: checkPreparedTransactions},
		{name: "table_vacuum_debt", fn: checkTableVacuumDebt},
		{name: "wraparound", fn: checkWraparound},
		{name: "replication_slots", fn: checkReplicationSlots},
	}

	encoder := json.NewEncoder(os.Stdout)
	for {
		report := runChecks(ctx, pool, policy, checks)
		if err := encoder.Encode(report); err != nil {
			slog.Error("encode report", "error", err)
			return
		}

		timer := time.NewTimer(policy.CheckInterval)
		select {
		case <-ctx.Done():
			if !timer.Stop() {
				<-timer.C
			}
			slog.Info("shutdown", "reason", ctx.Err())
			return
		case <-timer.C:
		}
	}
}

func runChecks(ctx context.Context, pool *pgxpool.Pool, p Policy, checks []namedCheck) Report {
	sem := make(chan struct{}, p.MaxConcurrentChecks)
	results := make(chan checkResult, len(checks))
	var wg sync.WaitGroup

	for _, item := range checks {
		item := item
		wg.Add(1)
		go func() {
			defer wg.Done()
			select {
			case sem <- struct{}{}:
				defer func() { <-sem }()
			case <-ctx.Done():
				results <- checkResult{name: item.name, err: ctx.Err()}
				return
			}

			checkCtx, cancel := context.WithTimeout(ctx, p.QueryTimeout)
			defer cancel()
			findings, err := item.fn(checkCtx, pool, p)
			results <- checkResult{name: item.name, findings: findings, err: err}
		}()
	}

	wg.Wait()
	close(results)

	report := Report{At: time.Now().UTC()}
	for result := range results {
		report.Findings = append(report.Findings, result.findings...)
		if result.err != nil {
			report.Errors = append(report.Errors, classifyError(result.name, result.err))
		}
	}

	sort.Slice(report.Findings, func(i, j int) bool {
		if report.Findings[i].Severity != report.Findings[j].Severity {
			return report.Findings[i].Severity < report.Findings[j].Severity
		}
		if report.Findings[i].Check != report.Findings[j].Check {
			return report.Findings[i].Check < report.Findings[j].Check
		}
		return report.Findings[i].Object < report.Findings[j].Object
	})

	stat := pool.Stat()
	report.Pool = PoolSnapshot{
		AcquiredConns: stat.AcquiredConns(),
		IdleConns:     stat.IdleConns(),
		TotalConns:    stat.TotalConns(),
		MaxConns:      stat.MaxConns(),
		AcquireCount:  stat.AcquireCount(),
		AcquireTime:   stat.AcquireDuration(),
	}
	return report
}

func checkTransactions(ctx context.Context, pool *pgxpool.Pool, p Policy) (findings []Finding, err error) {
	const query = `
SELECT
    pid,
    COALESCE(datname, ''),
    COALESCE(usename, ''),
    COALESCE(application_name, ''),
    COALESCE(client_addr::text, ''),
    state,
    extract(epoch FROM clock_timestamp() - xact_start)::float8 AS age_seconds,
    COALESCE(age(backend_xmin), 0)::bigint AS xmin_age,
    COALESCE(wait_event_type, ''),
    COALESCE(wait_event, ''),
    left(query, 200)
FROM pg_stat_activity
WHERE pid <> pg_backend_pid()
  AND xact_start IS NOT NULL
  AND (
      (state LIKE 'idle in transaction%'
       AND extract(epoch FROM clock_timestamp() - xact_start) >= $1)
   OR (state NOT LIKE 'idle in transaction%'
       AND extract(epoch FROM clock_timestamp() - xact_start) >= $2)
  )
ORDER BY xact_start`

	rows, err := pool.Query(ctx, query, p.IdleInTxThreshold.Seconds(), p.LongTxThreshold.Seconds())
	if err != nil {
		return nil, err
	}
	defer finishRows(rows, &err)

	for rows.Next() {
		var pid int32
		var database, user, app, client, state string
		var ageSeconds float64
		var xminAge int64
		var waitType, waitEvent, sample string
		if scanErr := rows.Scan(
			&pid, &database, &user, &app, &client, &state,
			&ageSeconds, &xminAge, &waitType, &waitEvent, &sample,
		); scanErr != nil {
			return nil, scanErr
		}

		severity := "warning"
		if state == "idle in transaction (aborted)" {
			severity = "critical"
		}
		findings = append(findings, Finding{
			Check:    "transactions",
			Severity: severity,
			Object:   fmt.Sprintf("pid=%d", pid),
			Message:  "transaction exceeds the configured policy",
			Data: map[string]any{
				"database": database, "user": user, "application": app,
				"client": client, "state": state, "age_seconds": ageSeconds,
				"backend_xmin_age": xminAge, "wait_event_type": waitType,
				"wait_event": waitEvent, "query_sample": sample,
			},
		})
	}
	return findings, nil
}

func checkPreparedTransactions(ctx context.Context, pool *pgxpool.Pool, p Policy) (findings []Finding, err error) {
	const query = `
SELECT
    gid,
    owner::text,
    database::text,
    extract(epoch FROM clock_timestamp() - prepared)::float8 AS age_seconds
FROM pg_prepared_xacts
WHERE extract(epoch FROM clock_timestamp() - prepared) >= $1
ORDER BY prepared`

	rows, err := pool.Query(ctx, query, p.LongTxThreshold.Seconds())
	if err != nil {
		return nil, err
	}
	defer finishRows(rows, &err)

	for rows.Next() {
		var gid, owner, database string
		var ageSeconds float64
		if scanErr := rows.Scan(&gid, &owner, &database, &ageSeconds); scanErr != nil {
			return nil, scanErr
		}
		findings = append(findings, Finding{
			Check:    "prepared_transactions",
			Severity: "critical",
			Object:   gid,
			Message:  "prepared transaction exceeds the configured policy; resolve through the transaction coordinator",
			Data: map[string]any{
				"owner": owner, "database": database, "age_seconds": ageSeconds,
			},
		})
	}
	return findings, nil
}

func checkTableVacuumDebt(ctx context.Context, pool *pgxpool.Pool, p Policy) (findings []Finding, err error) {
	const query = `
WITH global_cfg AS (
    SELECT
        current_setting('autovacuum_vacuum_threshold')::float8 AS base_threshold,
        current_setting('autovacuum_vacuum_scale_factor')::float8 AS scale_factor,
        COALESCE(current_setting('autovacuum_vacuum_max_threshold', true), '-1')::float8 AS max_threshold
), base AS (
    SELECT
        s.relid,
        s.relid::regclass::text AS relation,
        pg_total_relation_size(s.relid)::bigint AS total_bytes,
        s.n_live_tup::bigint,
        s.n_dead_tup::bigint,
        s.last_vacuum,
        s.last_autovacuum,
        GREATEST(c.reltuples, 0)::float8 AS reltuples,
        COALESCE((
            SELECT option_value::float8
            FROM pg_options_to_table(c.reloptions)
            WHERE option_name = 'autovacuum_vacuum_threshold'
        ), g.base_threshold) AS base_threshold,
        COALESCE((
            SELECT option_value::float8
            FROM pg_options_to_table(c.reloptions)
            WHERE option_name = 'autovacuum_vacuum_scale_factor'
        ), g.scale_factor) AS scale_factor,
        COALESCE((
            SELECT option_value::float8
            FROM pg_options_to_table(c.reloptions)
            WHERE option_name = 'autovacuum_vacuum_max_threshold'
        ), g.max_threshold) AS max_threshold,
        COALESCE((
            SELECT option_value::boolean
            FROM pg_options_to_table(c.reloptions)
            WHERE option_name = 'autovacuum_enabled'
        ), true) AS autovacuum_enabled
    FROM pg_stat_user_tables AS s
    JOIN pg_class AS c ON c.oid = s.relid
    CROSS JOIN global_cfg AS g
), calc AS (
    SELECT
        *,
        CASE
            WHEN max_threshold < 0
                THEN base_threshold + scale_factor * reltuples
            ELSE LEAST(max_threshold, base_threshold + scale_factor * reltuples)
        END AS estimated_trigger,
        n_dead_tup::float8 / NULLIF(n_live_tup + n_dead_tup, 0) AS dead_ratio,
        extract(epoch FROM clock_timestamp() - COALESCE(
            CASE
                WHEN last_vacuum IS NULL THEN last_autovacuum
                WHEN last_autovacuum IS NULL THEN last_vacuum
                ELSE GREATEST(last_vacuum, last_autovacuum)
            END,
            'epoch'::timestamptz
        ))::float8 AS maintenance_age_seconds
    FROM base
)
SELECT
    relation,
    total_bytes,
    n_live_tup,
    n_dead_tup,
    COALESCE(dead_ratio, 0)::float8,
    estimated_trigger::float8,
    (n_dead_tup >= estimated_trigger) AS trigger_exceeded,
    maintenance_age_seconds,
    autovacuum_enabled,
    COALESCE(last_vacuum::text, ''),
    COALESCE(last_autovacuum::text, '')
FROM calc
WHERE total_bytes >= $1
  AND (
      COALESCE(dead_ratio, 0) >= $2
      OR (n_dead_tup >= estimated_trigger AND maintenance_age_seconds >= $3)
  )
ORDER BY total_bytes DESC`

	rows, err := pool.Query(
		ctx, query,
		p.MinTableBytes,
		p.DeadTupleRatio,
		p.AutovacuumLag.Seconds(),
	)
	if err != nil {
		return nil, err
	}
	defer finishRows(rows, &err)

	for rows.Next() {
		var relation, lastVacuum, lastAutovacuum string
		var totalBytes, live, dead int64
		var deadRatio, trigger, maintenanceAge float64
		var triggerExceeded, enabled bool
		if scanErr := rows.Scan(
			&relation, &totalBytes, &live, &dead, &deadRatio, &trigger,
			&triggerExceeded, &maintenanceAge, &enabled, &lastVacuum, &lastAutovacuum,
		); scanErr != nil {
			return nil, scanErr
		}

		severity := "warning"
		if triggerExceeded && maintenanceAge >= p.AutovacuumLag.Seconds() {
			severity = "critical"
		}
		if !enabled {
			severity = "critical"
		}
		findings = append(findings, Finding{
			Check:    "table_vacuum_debt",
			Severity: severity,
			Object:   relation,
			Message:  "table exceeds the configured dead-tuple or autovacuum-lag policy; n_dead_tup and reltuples are estimates",
			Data: map[string]any{
				"total_bytes": totalBytes, "n_live_tup": live, "n_dead_tup": dead,
				"dead_ratio": deadRatio, "estimated_trigger": trigger,
				"trigger_exceeded":        triggerExceeded,
				"maintenance_age_seconds": maintenanceAge,
				"autovacuum_enabled":      enabled,
				"last_vacuum":             lastVacuum, "last_autovacuum": lastAutovacuum,
			},
		})
	}
	return findings, nil
}

func checkWraparound(ctx context.Context, pool *pgxpool.Pool, p Policy) (findings []Finding, err error) {
	const databaseQuery = `
WITH cfg AS (
    SELECT
        current_setting('autovacuum_freeze_max_age')::float8 AS xid_trigger,
        current_setting('autovacuum_multixact_freeze_max_age')::float8 AS mxid_trigger
)
SELECT
    d.datname,
    age(d.datfrozenxid)::bigint AS xid_age,
    cfg.xid_trigger,
    age(d.datfrozenxid)::float8 / NULLIF(cfg.xid_trigger, 0) AS xid_ratio,
    mxid_age(d.datminmxid)::bigint AS mxid_age,
    cfg.mxid_trigger,
    mxid_age(d.datminmxid)::float8 / NULLIF(cfg.mxid_trigger, 0) AS mxid_ratio
FROM pg_database AS d
CROSS JOIN cfg
WHERE age(d.datfrozenxid)::float8 / NULLIF(cfg.xid_trigger, 0) >= $1
   OR mxid_age(d.datminmxid)::float8 / NULLIF(cfg.mxid_trigger, 0) >= $2
ORDER BY GREATEST(
    age(d.datfrozenxid)::float8 / NULLIF(cfg.xid_trigger, 0),
    mxid_age(d.datminmxid)::float8 / NULLIF(cfg.mxid_trigger, 0)
) DESC`

	rows, err := pool.Query(ctx, databaseQuery, p.XIDTriggerRatio, p.MXIDTriggerRatio)
	if err != nil {
		return nil, err
	}
	for rows.Next() {
		var database string
		var xidAge, mxidAge int64
		var xidTrigger, xidRatio, mxidTrigger, mxidRatio float64
		if scanErr := rows.Scan(
			&database, &xidAge, &xidTrigger, &xidRatio,
			&mxidAge, &mxidTrigger, &mxidRatio,
		); scanErr != nil {
			rows.Close()
			return nil, scanErr
		}
		findings = append(findings, Finding{
			Check:    "wraparound",
			Severity: "critical",
			Object:   "database=" + database,
			Message:  "database XID or MultiXact age exceeds the configured fraction of the autovacuum trigger",
			Data: map[string]any{
				"xid_age": xidAge, "xid_trigger": xidTrigger, "xid_ratio": xidRatio,
				"multixact_age": mxidAge, "multixact_trigger": mxidTrigger,
				"multixact_ratio": mxidRatio,
			},
		})
	}
	rows.Close()
	if err := rows.Err(); err != nil {
		return nil, err
	}

	const relationQuery = `
WITH cfg AS (
    SELECT
        current_setting('autovacuum_freeze_max_age')::float8 AS xid_trigger,
        current_setting('autovacuum_multixact_freeze_max_age')::float8 AS mxid_trigger
)
SELECT
    c.oid::regclass::text AS relation,
    pg_total_relation_size(c.oid)::bigint AS total_bytes,
    age(c.relfrozenxid)::bigint AS xid_age,
    age(c.relfrozenxid)::float8 / NULLIF(cfg.xid_trigger, 0) AS xid_ratio,
    mxid_age(c.relminmxid)::bigint AS mxid_age,
    mxid_age(c.relminmxid)::float8 / NULLIF(cfg.mxid_trigger, 0) AS mxid_ratio
FROM pg_class AS c
JOIN pg_namespace AS n ON n.oid = c.relnamespace
CROSS JOIN cfg
WHERE c.relkind IN ('r', 'm', 't')
  AND (
      age(c.relfrozenxid)::float8 / NULLIF(cfg.xid_trigger, 0) >= $1
      OR mxid_age(c.relminmxid)::float8 / NULLIF(cfg.mxid_trigger, 0) >= $2
  )
ORDER BY GREATEST(
    age(c.relfrozenxid)::float8 / NULLIF(cfg.xid_trigger, 0),
    mxid_age(c.relminmxid)::float8 / NULLIF(cfg.mxid_trigger, 0)
) DESC
LIMIT 50`

	rows, err = pool.Query(ctx, relationQuery, p.XIDTriggerRatio, p.MXIDTriggerRatio)
	if err != nil {
		return findings, err
	}
	defer finishRows(rows, &err)
	for rows.Next() {
		var relation string
		var totalBytes, xidAge, mxidAge int64
		var xidRatio, mxidRatio float64
		if scanErr := rows.Scan(&relation, &totalBytes, &xidAge, &xidRatio, &mxidAge, &mxidRatio); scanErr != nil {
			return findings, scanErr
		}
		findings = append(findings, Finding{
			Check:    "wraparound",
			Severity: "critical",
			Object:   relation,
			Message:  "relation freeze horizon exceeds the configured policy",
			Data: map[string]any{
				"total_bytes": totalBytes, "xid_age": xidAge,
				"xid_ratio": xidRatio, "multixact_age": mxidAge,
				"multixact_ratio": mxidRatio,
			},
		})
	}
	return findings, nil
}

func checkReplicationSlots(ctx context.Context, pool *pgxpool.Pool, p Policy) (findings []Finding, err error) {
	const query = `
WITH cfg AS (
    SELECT
        CASE WHEN pg_is_in_recovery()
             THEN pg_last_wal_replay_lsn()
             ELSE pg_current_wal_lsn()
        END AS current_lsn,
        current_setting('autovacuum_freeze_max_age')::float8 AS xid_trigger
), slots AS (
    SELECT
        s.slot_name,
        s.slot_type,
        s.active,
        COALESCE(pg_wal_lsn_diff(cfg.current_lsn, s.restart_lsn), 0)::float8 AS retained_wal_bytes,
        COALESCE(age(s.xmin), 0)::bigint AS xmin_age,
        COALESCE(age(s.catalog_xmin), 0)::bigint AS catalog_xmin_age,
        COALESCE(age(s.xmin), 0)::float8 / NULLIF(cfg.xid_trigger, 0) AS xmin_ratio,
        COALESCE(age(s.catalog_xmin), 0)::float8 / NULLIF(cfg.xid_trigger, 0) AS catalog_xmin_ratio
    FROM pg_replication_slots AS s
    CROSS JOIN cfg
)
SELECT
    slot_name, slot_type, active, retained_wal_bytes,
    xmin_age, catalog_xmin_age, xmin_ratio, catalog_xmin_ratio
FROM slots
WHERE retained_wal_bytes >= $1
   OR xmin_ratio >= $2
   OR catalog_xmin_ratio >= $2
ORDER BY retained_wal_bytes DESC`

	rows, err := pool.Query(ctx, query, p.SlotRetainedWALBytes, p.XIDTriggerRatio)
	if err != nil {
		return nil, err
	}
	defer finishRows(rows, &err)

	for rows.Next() {
		var name, slotType string
		var active bool
		var retained float64
		var xminAge, catalogXminAge int64
		var xminRatio, catalogRatio float64
		if scanErr := rows.Scan(
			&name, &slotType, &active, &retained,
			&xminAge, &catalogXminAge, &xminRatio, &catalogRatio,
		); scanErr != nil {
			return nil, scanErr
		}
		findings = append(findings, Finding{
			Check:    "replication_slots",
			Severity: "critical",
			Object:   name,
			Message:  "slot exceeds a WAL-retention or tuple-horizon policy; diagnose these dimensions separately",
			Data: map[string]any{
				"slot_type": slotType, "active": active,
				"retained_wal_bytes": retained,
				"xmin_age":           xminAge, "catalog_xmin_age": catalogXminAge,
				"xmin_ratio": xminRatio, "catalog_xmin_ratio": catalogRatio,
			},
		})
	}
	return findings, nil
}

func finishRows(rows pgx.Rows, errp *error) {
	rows.Close()
	if rowsErr := rows.Err(); rowsErr != nil && *errp == nil {
		*errp = rowsErr
	}
}

func classifyError(check string, err error) CheckError {
	result := CheckError{Check: check, Message: errorMessage(err)}
	var pgErr *pgconn.PgError
	if errors.As(err, &pgErr) {
		result.SQLState = pgErr.Code
	}
	return result
}

func errorMessage(err error) string {
	var pgErr *pgconn.PgError
	if errors.As(err, &pgErr) {
		return fmt.Sprintf("%s (SQLSTATE %s)", pgErr.Message, pgErr.Code)
	}
	return err.Error()
}

func loadPolicy() (Policy, error) {
	var p Policy
	var err error
	if p.CheckInterval, err = requiredDuration("CHECK_INTERVAL"); err != nil {
		return p, err
	}
	if p.QueryTimeout, err = requiredDuration("QUERY_TIMEOUT"); err != nil {
		return p, err
	}
	if p.LongTxThreshold, err = requiredDuration("LONG_TX_THRESHOLD"); err != nil {
		return p, err
	}
	if p.IdleInTxThreshold, err = requiredDuration("IDLE_IN_TX_THRESHOLD"); err != nil {
		return p, err
	}
	if p.AutovacuumLag, err = requiredDuration("AUTOVACUUM_LAG_THRESHOLD"); err != nil {
		return p, err
	}
	if p.DeadTupleRatio, err = requiredRatio("DEAD_TUPLE_RATIO"); err != nil {
		return p, err
	}
	if p.MinTableBytes, err = requiredInt64("MIN_TABLE_BYTES"); err != nil {
		return p, err
	}
	if p.XIDTriggerRatio, err = requiredRatio("XID_TRIGGER_RATIO"); err != nil {
		return p, err
	}
	if p.MXIDTriggerRatio, err = requiredRatio("MXID_TRIGGER_RATIO"); err != nil {
		return p, err
	}
	if p.SlotRetainedWALBytes, err = requiredInt64("SLOT_RETAINED_WAL_BYTES"); err != nil {
		return p, err
	}

	maxChecks, err := requiredInt("MAX_CONCURRENT_CHECKS")
	if err != nil {
		return p, err
	}
	maxConns, err := requiredInt("DB_MAX_CONNS")
	if err != nil {
		return p, err
	}
	if maxChecks < 1 || maxConns < 1 || maxConns > int(^uint32(0)>>1) {
		return p, fmt.Errorf("MAX_CONCURRENT_CHECKS and DB_MAX_CONNS must be positive and DB_MAX_CONNS must fit int32")
	}
	if maxConns < maxChecks {
		return p, fmt.Errorf("DB_MAX_CONNS must be >= MAX_CONCURRENT_CHECKS")
	}
	p.MaxConcurrentChecks = maxChecks
	p.DBMaxConns = int32(maxConns)
	return p, nil
}

func requiredDuration(name string) (time.Duration, error) {
	value, err := requiredEnv(name)
	if err != nil {
		return 0, err
	}
	d, err := time.ParseDuration(value)
	if err != nil || d <= 0 {
		return 0, fmt.Errorf("%s must be a positive Go duration: %q", name, value)
	}
	return d, nil
}

func requiredRatio(name string) (float64, error) {
	value, err := requiredEnv(name)
	if err != nil {
		return 0, err
	}
	ratio, err := strconv.ParseFloat(value, 64)
	if err != nil || ratio <= 0 || ratio > 1 {
		return 0, fmt.Errorf("%s must be in (0,1]: %q", name, value)
	}
	return ratio, nil
}

func requiredInt64(name string) (int64, error) {
	value, err := requiredEnv(name)
	if err != nil {
		return 0, err
	}
	n, err := strconv.ParseInt(value, 10, 64)
	if err != nil || n < 0 {
		return 0, fmt.Errorf("%s must be a non-negative base-10 integer: %q", name, value)
	}
	return n, nil
}

func requiredInt(name string) (int, error) {
	value, err := requiredEnv(name)
	if err != nil {
		return 0, err
	}
	n, err := strconv.Atoi(value)
	if err != nil {
		return 0, fmt.Errorf("%s must be an integer: %q", name, value)
	}
	return n, nil
}

func requiredEnv(name string) (string, error) {
	value := os.Getenv(name)
	if value == "" {
		return "", fmt.Errorf("%s is required", name)
	}
	return value, nil
}

初始化依赖时使用当前稳定的 pgx/v5,不在源码中锁死补丁版本:

go mod init example.com/vacuum-health

go get github.com/jackc/pgx/v5

go run .

下面只是本地实验的 policy 示例,不是生产建议值

export DATABASE_URL='postgres://monitor:[email protected]:5432/app?sslmode=require'
export CHECK_INTERVAL='30s'
export QUERY_TIMEOUT='5s'
export LONG_TX_THRESHOLD='2m'
export IDLE_IN_TX_THRESHOLD='30s'
export AUTOVACUUM_LAG_THRESHOLD='10m'
export DEAD_TUPLE_RATIO='0.10'
export MIN_TABLE_BYTES='1073741824'
export XID_TRIGGER_RATIO='0.70'
export MXID_TRIGGER_RATIO='0.70'
export SLOT_RETAINED_WAL_BYTES='10737418240'
export MAX_CONCURRENT_CHECKS='3'
export DB_MAX_CONNS='4'

生产 policy 应来自以下约束:

  • 长事务阈值:业务正常事务 P99、批处理设计上限和 idle_in_transaction_session_timeout
  • Dead Tuple:表的变更速率、行宽、索引数、可接受 bloat 窗口与清理吞吐;
  • Autovacuum lag:不是“距离上次 vacuum 的固定时间”,而是表已达到触发条件后允许排队多久;
  • XID/MXID:以触发阈值比例 + 当前每秒消耗斜率计算 time-to-trigger/time-to-stop;
  • Slot WAL:pg_wal、归档、对象存储和恢复容量;
  • 表大小过滤:防止大量小表噪声,但小表环绕风险仍由独立检查覆盖。

监控角色至少需要读取相关统计视图;通常可授予 pg_monitor,再按组织安全模型补充最小权限。不要给健康检查器超级用户、终止后端或修改 Slot 的权限。

16. 生产 Runbook

16.1 第一步:确认告警对象和时间线

先回答五个问题:

  1. 告警来自哪个 Cluster、Primary/Standby、Database、Schema、Relation/TOAST/Index?
  2. 何时开始增长?是否紧邻发布、批处理、迁移、CDC 中断、只读报表或流量峰值?
  3. 是文件大小大、增长斜率异常、查询变慢、XID 年龄上升,还是同时发生?
  4. 统计是否刚 reset、实例是否刚重启、Relation 是否刚重写?
  5. 当前 SLO、磁盘余量、WAL/归档余量、Replica lag 和预计耗尽时间是多少?
SELECT now(), version(), pg_is_in_recovery();
SELECT stats_reset FROM pg_stat_database WHERE datname = current_database();
SELECT pg_postmaster_start_time();

不要把 n_dead_tup 的一次跳变直接判定为 bloat。先对齐统计 reset、发布和流量时间线。

16.2 第二步:看维护债务与增长速率

SELECT
    relid::regclass AS relation,
    pg_total_relation_size(relid) AS total_bytes,
    n_live_tup,
    n_dead_tup,
    n_ins_since_vacuum,
    n_mod_since_analyze,
    last_vacuum,
    last_autovacuum,
    last_analyze,
    last_autoanalyze,
    vacuum_count,
    autovacuum_count
FROM pg_stat_user_tables
ORDER BY pg_total_relation_size(relid) DESC
LIMIT 100;

重要字段:

  • total_bytes:Heap、TOAST、Index 合计;必须再拆分定位;
  • n_dead_tup:估算,不是可直接清理的精确数量;
  • n_ins_since_vacuum:append-only 表是否需要 insert-trigger vacuum/VM/freeze;
  • n_mod_since_analyze:Planner 统计是否落后;
  • last_* 和 count:辨别从未运行、运行过但无效、还是运行频率不足。

将这些值作为时间序列采样,计算:

垃圾生成速率 = Δn_dead_tup 或业务 UPDATE/DELETE/s
清理速率     = 每次 vacuum 前后变化 / 持续时间
空间增长速率 = Δpg_total_relation_size / Δt
耗尽时间     = 剩余磁盘 / 净增长速率

16.3 第三步:查看正在运行或排队的维护

SELECT
    a.pid, a.backend_type, a.state,
    a.wait_event_type, a.wait_event,
    a.query_start,
    clock_timestamp() - a.query_start AS runtime,
    p.relid::regclass AS relation,
    p.phase,
    p.heap_blks_total,
    p.heap_blks_scanned,
    p.heap_blks_vacuumed,
    p.index_vacuum_count,
    p.dead_tuple_bytes,
    p.num_dead_item_ids,
    p.delay_time
FROM pg_stat_activity AS a
LEFT JOIN pg_stat_progress_vacuum AS p ON p.pid = a.pid
WHERE a.backend_type = 'autovacuum worker'
   OR p.pid IS NOT NULL
ORDER BY a.query_start;

判断:

  • scanning heap 是否持续前进;
  • index_vacuum_count 是否多轮增长,提示 dead TID 很多或内存/索引成本高;
  • delay_time 是否主要由 cost 节流造成 [PG18];
  • wait_event 是 Relation Lock、Buffer Pin、I/O 还是无等待但吞吐低;
  • 任务是否为 to prevent wraparound,这种 Worker 不应随意取消。

同时检查日志中的 Autovacuum duration、pages、tuples、index scans、WAL 和 I/O;log_autovacuum_min_duration 应按观测策略设置,而不是永久只记录极慢任务。

16.4 第四步:找 blocker 与逻辑 horizon blocker

锁 blocker:

SELECT
    blocked.pid AS blocked_pid,
    blocked.wait_event_type,
    blocked.wait_event,
    blocker.pid AS blocker_pid,
    blocker.state AS blocker_state,
    clock_timestamp() - blocker.xact_start AS blocker_xact_age,
    left(blocked.query, 160) AS blocked_query,
    left(blocker.query, 160) AS blocker_query
FROM pg_stat_activity AS blocked
CROSS JOIN LATERAL unnest(pg_blocking_pids(blocked.pid)) AS b(pid)
JOIN pg_stat_activity AS blocker ON blocker.pid = b.pid
ORDER BY blocker.xact_start;

逻辑地平线 blocker:

SELECT
    pid, usename, application_name, client_addr,
    state, xact_start,
    clock_timestamp() - xact_start AS xact_age,
    backend_xmin, age(backend_xmin) AS xmin_age,
    wait_event_type, wait_event,
    left(query, 200)
FROM pg_stat_activity
WHERE backend_xmin IS NOT NULL OR xact_start IS NOT NULL
ORDER BY backend_xmin NULLS LAST, xact_start;

SELECT * FROM pg_prepared_xacts ORDER BY prepared;

WITH wal_position AS (
    SELECT CASE
             WHEN pg_is_in_recovery() THEN pg_last_wal_replay_lsn()
             ELSE pg_current_wal_lsn()
           END AS current_lsn
)
SELECT
    s.slot_name, s.slot_type, s.active,
    s.restart_lsn,
    COALESCE(pg_wal_lsn_diff(w.current_lsn, s.restart_lsn), 0) AS retained_wal_bytes,
    s.xmin, age(s.xmin), s.catalog_xmin, age(s.catalog_xmin)
FROM pg_replication_slots AS s
CROSS JOIN wal_position AS w;

WITH wal_position AS (
    SELECT CASE
             WHEN pg_is_in_recovery() THEN pg_last_wal_replay_lsn()
             ELSE pg_current_wal_lsn()
           END AS current_lsn
)
SELECT
    r.pid, r.application_name, r.state, r.backend_xmin,
    pg_wal_lsn_diff(w.current_lsn, r.replay_lsn) AS replay_bytes
FROM pg_stat_replication AS r
CROSS JOIN wal_position AS w;

pg_stat_replication.backend_xmin 是 Standby 通过 hot standby feedback 报告的 horizon。若它很老,继续在主库加 Worker 并不能解决不可清理问题。

16.5 第五步:找到最早出现的执行计划估算错误

维护告警常与统计陈旧同时出现。流程:

  1. 从 APM/pg_stat_statements 找到延迟、shared blocks 或 temp/WAL 在故障起点首次偏离的 queryid
  2. 比对发布前后规范化 SQL、参数分布和 Plan;
  3. 检查目标表的 n_mod_since_analyzelast_autoanalyze、列 statistics target 与扩展统计;
  4. 在可控环境执行 EXPLAIN (ANALYZE, BUFFERS, WAL, SETTINGS, VERBOSE, SUMMARY),逐节点比较 estimated rows 与 actual rows,找到第一个数量级偏差,而不是只看最上层总耗时;
  5. ANALYZE 后重测。若估算修复但 I/O 仍高,问题更可能是物理 bloat/VM,而不是 Planner。
SELECT
    queryid, calls, total_exec_time, mean_exec_time,
    rows, shared_blks_hit, shared_blks_read,
    temp_blks_read, temp_blks_written,
    wal_records, wal_fpi, wal_bytes,
    left(query, 240) AS sample
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 30;

pg_stat_statements 是扩展,字段随版本/配置不同;不存在时使用 APM、日志和采样 Explain。对写语句运行 EXPLAIN ANALYZE 会真正执行,生产上优先在克隆数据或安全事务中验证,并考虑不可回滚副作用。

16.6 第六步:区分 CPU、内存、I/O、锁、连接池、WAL、Vacuum 与复制

症状证据进一步确认常见误判
CPU 饱和OS CPU、run queue;VACUUM/前台均 runnablepg_stat_activity、perf/采样、索引数量、Tuple 判断量只看低 I/O 就认定无压力
内存压力RSS、swap/OOM、Worker 并发maintenance_work_mem × 并发维护、shared buffers、OS cache只看单 Worker 内存
I/O 饱和device latency/queue;DataFileRead/Writepg_stat_io、Buffers、Checkpoint、AIO 队列把所有 I/O 都归因于 VACUUM
锁问题wait_event_type='Lock'、blocking PIDspg_locks 模式与队列;是否 FULL/DDL终止 Autovacuum 而忽略前方 DDL
连接池问题acquire duration、waiters、acquired=max活跃 SQL 数、事务年龄、goroutine 队列增加连接掩盖长事务
WAL 问题WAL/s、wal_fpi、归档失败pg_stat_wal、Checkpoint、Slot retained WAL只扩 pg_wal 不修消费者
Vacuum 问题触发后长时间未运行/清理速率不足progress、日志、每表 reloptions、Worker 队列n_dead_tup=0 当作无 bloat
复制问题replay lag、recovery conflict、feedback xminpg_stat_replicationpg_stat_database_conflicts、Slot只看时间 lag,不看 bytes/LSN

PG18 示例:

SELECT * FROM pg_stat_io;
SELECT * FROM pg_stat_wal;
SELECT * FROM pg_stat_checkpointer;
SELECT * FROM pg_stat_database_conflicts;

这些累计视图必须结合 stats_reset 和采样间隔求速率。

16.7 第七步:先确认环绕优先级

SELECT
    datname,
    age(datfrozenxid) AS xid_age,
    mxid_age(datminmxid) AS mxid_age
FROM pg_database
ORDER BY age(datfrozenxid) DESC;

再列出最老 Relation。若 XID/MXID time-to-trigger 很短,优先级高于“把索引压小”或“优化平均延迟”。先找 horizon blocker,再让 anti-wrap VACUUM 推进。不得通过调高 freeze max age 来隐藏告警;这只是在透支安全余量。

16.8 第八步:可在线执行与高风险操作分级

级别操作条件
通常在线ANALYZE;普通 VACUUM;查询统计视图;调整未来每表 Autovacuum reloptions仍需监控 I/O/WAL;先设合理 statement/lock policy
在线但需审批REINDEX CONCURRENTLY;取消明显失控的查询;降低写入准入;临时调整 Worker/cost评估额外空间、旧快照、SQLSTATE、业务重试和副本
高风险VACUUM FULL、普通 REINDEX、CLUSTER、pg_repack、终止业务事务、COMMIT/ROLLBACK PREPARED维护窗、负责人、容量、锁/WAL/复制/回滚计划
极高风险删除/推进 Slot、关闭 Autovacuum、提高环绕阈值掩盖风险、直接修改系统目录需要明确数据丢失/CDC 重建/停机恢复方案;通常禁止

16.9 第九步:临时止损

按根因选择,不要一次执行所有动作:

  1. 垃圾生成过快:对批处理/API 启用 admission control,降低并发或暂停非关键写入;不要让客户端无限重试。
  2. 长事务/idle:联系 owner;确认幂等和回滚影响后,优先让应用正常提交/回滚,再考虑 pg_cancel_backend/pg_terminate_backend
  3. Prepared Transaction:由事务协调器决议;若协调器丢失,按业务账本人工判定,不能猜。
  4. Slot:恢复消费者;确需删除/重建时先确认 CDC 重放起点、全量同步和可接受数据缺口。
  5. 环绕紧急:清除最老 horizon 后,对最老 Relation 执行普通 VACUUM;必要时暂时 INDEX_CLEANUP OFF, TRUNCATE OFF 优先推进冻结下界,之后补做索引治理。
  6. 前台 SLO 被维护冲击:调整单次维护成本/Worker 数、错峰和存储准入;不是简单停止所有 Autovacuum。
  7. Standby 冲突:缩短报表事务、路由到专用延迟副本,或有界开启 feedback;同步观察主库 bloat。
  8. 磁盘临界:先阻止净增长并清理安全可删的 WAL/临时文件;不要手工删除 pg_wal 或 Relation 文件。

16.10 第十步:根本修复

  • 按表设置 threshold/scale/insert/analyze 策略,[PG18] 用 max threshold 控制超大表上界;
  • 在 I/O、CPU、内存和副本容量允许下调整 Worker slots/max workers、cost delay/limit 与维护并发;
  • 缩短应用事务,设置有依据的 idle/transaction/statement timeout,把 RPC 移出事务;
  • 减少无价值索引,提升 HOT 比例,按实际行增长设置 fillfactor;
  • 对 append-only 分区在封存后执行 ANALYZE/FREEZE,并用分区 DROP 替代巨量 DELETE;
  • 为 Slot/CDC 定义 owner、最大离线时间、WAL 容量和重建流程;
  • 对报表副本设置查询时限、反馈策略和独立 SLO;
  • 对已形成的病理性 Heap/Index bloat 按决策表选择 REINDEX、FULL、repack 或分区迁移;
  • 把维护 WAL 纳入 max_wal_size、归档带宽、对象存储和副本容量规划。

16.11 第十一步:验证修复

至少连续跨越多个 Autovacuum 周期确认:

  • 触发后 Worker 能在 policy 窗口内开始并完成;
  • XID/MXID age 和 time-to-limit 下降或保持安全斜率;
  • n_dead_tup/实际 bloat 进入稳定波动,不再单调增长;
  • Heap/Index size 的净增长符合业务数据增长;
  • 核心查询的估算、Buffers、Heap Fetches、P95/P99 恢复;
  • WAL/归档、Checkpoint 和 Replica lag 无持续恶化;
  • 没有 invalid index、残留 repack 对象或异常 Slot;
  • 备份、PITR 与恢复校验仍通过。

16.12 第十二步:监控与告警

告警应覆盖“水平、斜率和剩余时间”:

  • 每表 dead/live ratio、dead tuple 生成速率、触发阈值超限持续时间;
  • 最后一次成功 vacuum/analyze 与累计 duration [PG18];
  • VACUUM phase 停滞、扫描速度、index vacuum cycles、cost delay;
  • 最老事务、最老 backend_xmin、idle in transaction、Prepared Transaction;
  • database/relation XID/MXID age、XID/s、time-to-trigger/time-to-stop;
  • Slot retained WAL、xmin/catalog_xmin 年龄分别告警;
  • Relation/Index/TOAST size 与磁盘/WAL/归档耗尽预测;
  • Index Only Scan Heap Fetches、核心计划估算偏差;
  • Autovacuum Worker 使用率/排队、CPU/I/O/Checkpoint;
  • 复制 byte/time lag、recovery conflicts、feedback horizon;
  • pgxpool acquire duration、acquired/max、取消/超时率和应用队列长度。

17. 常见错误与反模式

#反模式为什么错正确做法
1全局关闭 Autovacuum 来消除抖动把可控后台成本换成无界 bloat、统计陈旧和环绕停写找出具体表/阶段/I/O 根因,做每表调度、节流和容量治理
2十亿行表沿用默认 scale factor触发需要的变更量可能远超 SLO按变更速率和垃圾窗口推导 threshold/scale,[PG18] 使用 max cap
3n_dead_tup 当精确事实它是估算且受统计刷新/reset 影响看趋势,结合 VACUUM 日志、pgstattuple_approx、size 与 Buffers
4每晚对所有表 VACUUM FULL强锁、重写、额外磁盘与 WAL 会制造维护事故日常普通 VACUUM;只对确认的病理性 bloat 选择重写
5认为普通 VACUUM 后文件必须变小中间空页只能内部复用,通常不能返还 OS区分内部复用、尾部截断和完整重写
6看到 Autovacuum 慢就终止 Worker可能是 anti-wraparound;取消只让年龄更危险先看 query 文本、age、progress 和 blocker;优先清除 horizon
7调高 freeze max age 来“解决”告警只是缩短最终安全余量,根因仍在修复长事务/Slot/吞吐,尽早推进 Freeze
8把 Slot 的 restart_lsnxmin 混成一个告警WAL 磁盘和 Tuple 清理是不同故障路径分别监控 retained WAL、xmincatalog_xmin 和 owner
9无期限开启 hot_standby_feedback副本长查询可把主库 bloat 放大给查询和 feedback 定边界,必要时使用专用延迟副本
10事务内调用慢 RPC/等待用户固定 Snapshot、锁和连接,推迟 VACUUM把外部工作移出事务,使用 Outbox/Saga/可靠协调器
11autovacuum_work_mem 设很大而不乘 Worker 数并发 Worker 可造成总内存峰值和 OOM按并发维护上限做总量预算,观察实际 dead TID cycles
12盲目增加 Worker 和 cost limitI/O/WAL/副本可能先饱和,前台 P99 更差从净清理能力、设备队列、归档与复制容量联合调参
13REINDEX TABLE 解决 Heap bloat它只重建索引先拆分 Heap/Index/TOAST 大小,再选择对应工具
14对超大表高峰执行精确 pgstattuple全 Relation 扫描会争用 I/O 和缓存先用统计/近似扫描,低峰或克隆环境做精确诊断
15把 pg_repack 当“零锁、零风险”有短 AEL、长 SUE、额外盘、触发器、WAL 与失败残留dry-run、--no-kill-backend、容量/CDC/锁/恢复预演
16只监控普通 XID,不监控 MultiXact热点 Tuple Lock/外键可先耗尽 MultiXact同时监控 mxid_age(datminmxid/relminmxid) 与成员增长
17只看平均延迟和 TPSVACUUM/Checkpoint 往往首先破坏 P99记录 P50/P95/P99、设备时延、Wait、WAL 与 lag
18分区叶子自动分析,就假定父表统计总是新父表统计可能不会按预期自动更新为分区父表安排显式 ANALYZE,并核对 PG18 继承范围变化

18. 模拟生产事故案例

18.1 模拟生产案例一:大表默认阈值 + 长事务导致 P99 持续恶化

系统背景

订单主表约十亿行、12 个索引,峰值每秒数万次状态更新。Autovacuum 使用接近默认比例阈值;报表服务每天开启 Repeatable Read 事务,把查询结果分页导出到对象存储,事务内还会调用外部 API。

故障现象

  • 主表与多个索引每小时稳定增长,业务净数据量却基本不变;
  • 查询仍使用 Index Only Scan,但 Heap Fetches 从很低上升到大量;
  • 前台平均延迟变化不大,P99 和存储读时延周期性尖峰;
  • Autovacuum 日志显示任务运行过,团队因此认为“Vacuum 正常”;
  • 副本 lag 在维护窗口扩大。

错误假设

“Worker 数太少,直接从 3 提到 20 就能解决。”

排查过程

  1. 计算阈值发现比例项对应上亿 Dead Tuple,触发本身太晚;
  2. pg_stat_activity 发现报表会话 idle in transaction 数小时,backend_xmin 最老;
  3. VACUUM VERBOSE 显示大量版本不可移除;Worker 虽完成扫描,却没有清掉关键旧版本;
  4. n_tup_hot_upd/n_tup_upd 很低,因为状态列被多个索引覆盖;
  5. EXPLAIN (ANALYZE, BUFFERS) 显示 Index Only Scan 的 Heap Fetches 和 shared reads 增长;
  6. pg_stat_wal、归档和 pg_stat_replication 显示维护 WAL 把副本拖慢。

根因

  • 大表只依赖默认 scale factor,维护启动太晚;
  • 报表事务跨越外部 I/O,固定 Snapshot,旧版本长期为 Recently Dead;
  • 冗余索引降低 HOT 比例并扩大每次更新/清理成本;
  • 监控只看“最后一次 Autovacuum 时间”,没有看清理效果和 horizon。

临时止损

  • 与报表 owner 确认后取消并结束旧事务,将导出改为分段短事务;
  • 对写入做 admission control,降低垃圾生成峰值;
  • 先普通 VACUUM 关键分区/表,并监控 WAL/副本;
  • 暂不启动 FULL 或大量并发 REINDEX,避免磁盘和复制雪崩。

最终修复

  • 按每秒更新速率与目标垃圾窗口设置每表 threshold/scale,并在 PG18 设置 max threshold;
  • 报表改为游标/分块读取,每块独立事务,外部上传不在事务内;
  • 删除重复/低价值索引,调整 fillfactor 并验证 HOT 比例;
  • 只对确认严重的索引逐个 REINDEX CONCURRENTLY;若 Heap 必须缩小,在容量验证后使用分区迁移或在线 repack。

监控补充

增加最老 backend_xmin、触发阈值超限持续时间、VACUUM 前后 dead 清理量、Heap Fetches、size slope、WAL/s、Replica byte lag 和 pgxpool acquire P99。

防止复发

把事务时长 SLO、每表 Autovacuum policy 和索引变更成本纳入 Schema review;任何新增报表必须通过“事务内不得调用慢外部服务”的检查。


18.2 模拟生产案例二:反馈地平线与离线 CDC 同时制造主库双重磁盘压力

系统背景

Primary 有两个物理只读副本和一个逻辑 CDC Slot。分析副本开启 hot_standby_feedback,允许小时级查询;CDC 消费者因证书过期离线。团队只监控 Replica time lag,没有分别监控 Slot WAL 和 Tuple horizon。

故障现象

  • pg_wal 目录快速增长,归档队列落后;
  • 主库若干高更新表也持续膨胀;
  • 分析副本几乎不再取消长查询,看起来“读体验变好”;
  • Autovacuum 扫描 I/O 很高,清理效果却差;
  • 磁盘剩余空间进入小时级耗尽预测,故障切换演练被迫停止。

错误假设

“Replication Slot 只保留 WAL,扩容 pg_wal 磁盘就行;hot_standby_feedback 只影响副本。”

排查过程

  1. pg_replication_slots 显示逻辑 Slot 的 restart_lsn 落后,解释 WAL 保留;同时 catalog_xmin 很老,解释系统目录清理受阻;
  2. pg_stat_replication.backend_xmin 显示分析副本反馈的 xmin 更老,主库业务表旧版本因此不能回收;
  3. pg_stat_database_conflicts 低并不代表健康,只说明 feedback 把“查询取消”成本转移成主库 bloat;
  4. 长查询结束后,普通 VACUUM 才能清理 Heap;CDC 恢复后 restart_lsn 才推进;两条路径相互独立;
  5. 估算 FULL/repack 所需临时空间后发现当前磁盘根本无法安全重写。

根因

  • CDC 没有最大离线时间、证书到期告警和 Slot 重建 Runbook;
  • 分析副本允许无界长查询并永久 feedback;
  • 告警把 restart_lsncatalog_xmin 和 feedback backend_xmin 混为“复制 lag”;
  • 团队试图用扩盘代替生命周期治理。

临时止损

  • 修复证书并恢复 CDC;若无法恢复,按业务批准进行全量重建后再删除旧 Slot,明确接受位点变化;
  • 终止/缩短分析副本的失控查询,使 feedback horizon 前进;
  • 对非关键写入限流,保护磁盘和归档;
  • WAL 可回收、horizon 释放后执行普通 VACUUM,暂不做空间峰值过高的重写。

最终修复

  • Slot 配置 owner、retained WAL 上限、catalog_xmin age、消费者心跳和自动升级证书;
  • 报表迁移到专用延迟副本,设置查询时限和有界 feedback 策略;
  • 分别建立 WAL-retention、Tuple-retention、replay lag 和 recovery conflict 仪表盘;
  • 为磁盘、归档和 CDC 全量重建预留容量与演练窗口。

监控补充

告警 restart_lsn bytes、xmin/catalog_xmin age、pg_stat_replication.backend_xmin、最老 Standby query、主表 bloat slope、归档失败和 time-to-disk-full。

防止复发

任何新 Slot 或 feedback 变更必须绑定 owner、SLO、失联处置和容量预算;灾备演练必须包含“消费者离线 + 长报表查询”的组合故障,而不是只测试进程宕机。

19. 面试题

19.1 核心概念题(5 题)

题 1:Dead Tuple 与 Recently Dead 有什么区别?

  • 30 秒回答:Recently Dead 已被更新/删除,但仍可能被某个旧 Snapshot 看见,所以 VACUUM 不能移除;Dead 已越过全局可见性地平线,不再可能被任何相关快照看到,可以安全清理。
  • 深入回答:状态由删除/更新事务是否提交及 OldestXmin/Global Visibility 决定,不是按墙钟时间。长 Repeatable Read、Prepared Transaction、Slot xmin 或 feedback horizon 都会延长 Recently Dead。Page pruning/VACUUM 只有在确认 Dead 后才释放 Tuple;索引项还需 Index Cleanup。
  • 面试官真正考察:是否把 MVCC 可见性与物理回收联系起来。
  • 常见错误回答:“DELETE 后就是 Dead;VACUUM 立刻删掉。”
  • 追问:为什么一个没有锁等待的长事务也能导致 bloat?
  • 追问答案:它保留旧 Snapshot/backend_xmin,使旧版本仍有潜在观察者;VACUUM 不等待锁,却只能把它们留作 Recently Dead。

题 2:普通 VACUUM 和 VACUUM FULL 的本质差异是什么?

  • 30 秒回答:普通 VACUUM 在原 Relation 内清理并让空间复用,允许正常 DML,通常不缩文件;FULL 重写表和索引,取得 ACCESS EXCLUSIVE,能紧凑并返还空间,但阻塞、耗盘、耗 WAL。
  • 深入回答:普通 VACUUM 稳定 Block Number,只能截断表尾连续空页;FULL 创建新物理文件复制存活行并交换 relfilenode。FULL 适用于已确认的病理性 bloat 和维护窗,不是日常维护。在线替代有 pg_repack/分区迁移,但它们也有短强锁、额外空间和复制成本。
  • 面试官真正考察:能否从存储定位、锁和运维成本解释,而非只背语法。
  • 常见错误回答:“FULL 只是更深度、可以随时在线跑的 VACUUM。”
  • 追问:为什么 REINDEX TABLE 不能替代 FULL?
  • 追问答案:REINDEX 只重建索引,不重写 Heap;Heap 中空页、低密度页和 TOAST bloat 仍在。

题 3:FSM 与 Visibility Map 有什么区别?

  • 30 秒回答:FSM 记录页面可用空间,帮助写入找页;VM 记录 Heap Page 的 all-visible/all-frozen,帮助 VACUUM 跳页和 Index Only Scan 跳过 Heap 可见性检查。
  • 深入回答:DML 会清除 VM bit,VACUUM 验证后设置;all-frozen 是 all-visible 的更强状态。FSM 值是空间估算,可能滞后;VM 的正确性关系到查询可见性,设置更严格。索引自身没有 VM。
  • 面试官真正考察:是否理解辅助 Fork 对写路径、读路径和冻结的不同作用。
  • 常见错误回答:“两者都是记录空页,VM 用于索引空间。”
  • 追问:覆盖索引为什么仍有大量 Heap Fetches?
  • 追问答案:相关 Heap Page 未 all-visible,执行器必须访问 Heap 验证 MVCC;常见原因是近期 DML、Vacuum 落后或旧 horizon。

题 4:relfrozenxiddatfrozenxid 与 Freeze 是什么关系?

  • 30 秒回答:Freeze 把足够老的已提交 Tuple 标为永久可见语义;relfrozenxid 是 Relation 中无需再考虑更老普通 XID 的保守下界,datfrozenxid 汇总数据库内最老下界,用于环绕治理。
  • 深入回答:XID 是 32 位环形空间,普通历史 XID 不能永久参与前后比较。VACUUM 扫描并冻结后才能安全推进目录下界,再回收更老 pg_xact 状态。它们不是表中最小 xmin 的精确统计。TOAST 与物化视图也要纳入维护。
  • 面试官真正考察:能否解释为什么只删除数据仍需要 Vacuum/Freeze。
  • 常见错误回答:“冻结就是把表设成只读;relfrozenxid 是最后一次 Vacuum 的 XID。”
  • 追问:危急时为什么不优先 VACUUM FULL FREEZE
  • 追问答案:FULL 需要强锁和额外磁盘,可能根本拿不到锁或耗时更长;安全目标是尽快用普通/aggressive VACUUM 推进冻结边界。

题 5:Autovacuum 的 threshold、scale factor 和 insert threshold 如何工作?

  • 30 秒回答:更新/删除触发近似为 base threshold + scale factor × reltuples,[PG18] 可再由 max threshold 封顶;insert trigger 还按未 all-frozen 页面比例修正;ANALYZE 有独立公式。
  • 深入回答:统计是估算且调度受 Launcher 周期、Worker、锁和成本预算影响。大表只靠比例会触发过晚,小表只靠很小比例可能过频;应按垃圾生成速率、允许窗口、单次清理吞吐和硬件推导每表值。
  • 面试官真正考察:是否会从工作负载建模,而不是背默认值。
  • 常见错误回答:“scale factor 越小越好;所有表设成 0.01。”
  • 追问:append-only 表为什么也可能触发 Vacuum?
  • 追问答案:insert-trigger Vacuum 可推进 VM/all-frozen、冻结旧行并支持 Index Only Scan;没有 Dead Tuple 不等于不需要维护。

19.2 原理与排障题(6 题)

题 6:VACUUM 已成功,为什么表文件和 n_dead_tup 仍然很大?

  • 30 秒回答:文件不缩可能只是空间转为内部复用;n_dead_tup 是估算,也可能有旧 horizon 让版本不可移除,或统计尚未刷新。应拆分这三种情况。
  • 深入回答:检查 VACUUM VERBOSE 的 not removable、backend_xmin/Prepared/Slot、pgstattuple_approx、FSM/free space、Relation size 增长斜率和后续写入复用。若 Heap 已清理但文件必须返还 OS,才评估重写;若只是索引 bloat,REINDEX 更合适。
  • 面试官真正考察:是否会验证维护“效果”而非只看 command success。
  • 常见错误回答:“再跑一次 FULL 就一定对。”
  • 追问:如何证明空间已复用?
  • 追问答案:观察 VACUUM 后 free space 上升、后续等量 DML 文件增长显著变缓,同时 dead tuple 降低;必要时用 pg_freespacemap/pgstattuple 验证。

题 7:如何定位长事务阻止 VACUUM?

  • 30 秒回答:按 xact_startbackend_xmin 排序 pg_stat_activity,同时检查 pg_prepared_xacts、Slot xmin/catalog_xminpg_stat_replication.backend_xmin;再用 VACUUM VERBOSE 证明版本不可移除。
  • 深入回答:区分锁等待与可见性地平线:前者有 blockers/wait event,后者 Vacuum 可能正常扫描。联系 owner,先正常结束事务;终止前评估回滚、业务幂等和 Commit 不确定。Prepared Transaction 必须用 2PC 决议。
  • 面试官真正考察:是否理解“逻辑阻塞”以及生产终止风险。
  • 常见错误回答:“查询 pg_locks 找不到就说明没有 blocker。”
  • 追问:READ COMMITTED 长事务一定固定整个事务的 Snapshot 吗?
  • 追问答案:每条语句通常取新 Snapshot,影响与执行状态相关;Repeatable Read/Serializable 明确使用事务级 Snapshot。排障不能只看 isolation 名称,要看实际 backend_xmin、游标和会话状态。

题 8:Replication Slot 如何同时影响 WAL 和 VACUUM?

  • 30 秒回答restart_lsn 决定最早仍需 WAL,影响 pg_walxmin/catalog_xmin 决定普通/目录 Tuple 回收地平线,影响 Vacuum。它们必须分别监控。
  • 深入回答:离线物理 Slot 可能主要保留 WAL;逻辑 Slot 常同时持有 catalog horizon,但不能只凭类型推断。恢复消费者、删除 Slot、推进位点都有数据连续性成本。扩盘只延后 WAL 故障,不解除 Tuple horizon。
  • 面试官真正考察:复制与 MVCC 的边界是否准确。
  • 常见错误回答:“Slot 只防止 WAL 被删除;Vacuum 与它无关。”
  • 追问:删除逻辑 Slot 前要确认什么?
  • 追问答案:消费者可重建起点、是否接受 CDC 缺口、全量同步时间、下游幂等、HA 上 Slot 连续性,以及删除后 WAL/目录回收的验证方案。

题 9:hot_standby_feedback 的收益和风险是什么?

  • 30 秒回答:它让 Standby 报告最老 query horizon,Primary 延迟清理,从而减少 recovery conflict/查询取消;代价是主库旧版本保留和 bloat,尤其是无界长查询。
  • 深入回答:关闭 feedback 时,冲突成本表现为等待重放或取消查询,受 standby delay 参数约束;开启后成本转移到 Primary 空间、缓存和 Vacuum。架构上可用专用报表副本、查询时限、最大延迟和路由隔离平衡。
  • 面试官真正考察:是否能做一致性、可用性和容量取舍。
  • 常见错误回答:“开启只会让副本更稳定,没有主库成本。”
  • 追问:如何监控 feedback 的真实影响?
  • 追问答案:看 pg_stat_replication.backend_xmin、Standby 最老查询、主表 dead/size slope、recovery conflicts 与 replay lag,并做开关/查询时限前后对比。

题 10:Autovacuum 运行时 Index Only Scan 为什么仍退化?

  • 30 秒回答:运行不等于已完成,也不等于能设置 all-visible。页面有近期 DML、Recently Dead 或扫描尚未覆盖时,VM bit 未恢复,Index Only Scan 仍要 Heap Fetch。
  • 深入回答:检查 progress phase/blocks、长 horizon、VM 覆盖、relallvisible/relpages 与计划中的 Heap Fetches。解决可能是缩短事务、提前触发 Vacuum、降低 churn 或调整索引/工作负载,而非强制 Planner 使用某节点。
  • 面试官真正考察:能否把 VM 状态与执行器行为连接起来。
  • 常见错误回答:“只要有 INCLUDE 覆盖列就绝不会访问 Heap。”
  • 追问:DML 为什么必须清除 VM bit?
  • 追问答案:新/改 Tuple 的可见性取决于事务提交与快照;为保证正确性,页面不能继续宣称对所有事务 all-visible,直到 VACUUM 重新验证。

题 11:index_vacuum_count 多次增长说明什么,如何处理?

  • 30 秒回答:通常表示 Dead TID 存储达到预算,VACUUM 多次在 Heap 扫描与索引/Heap 清理间循环;也可能是大量 dead items。应结合 bytes、索引数、内存和 I/O 判断。
  • 深入回答:[PG17+] 看 max_dead_tuple_bytesdead_tuple_bytesnum_dead_item_ids。提高维护内存可能减少 cycles,但总内存会乘 Worker 数;减少无价值索引、让 Vacuum 更早运行或提高 HOT 可能更根本。紧急 Freeze 可暂时跳过 Index Cleanup,但会留下索引债务。
  • 面试官真正考察:是否会从进度状态推导内存、索引和工作负载成本。
  • 常见错误回答:“次数多就是 Vacuum 重复跑了多次,直接终止。”
  • 追问:为什么无限增加 maintenance_work_mem 不一定更快?
  • 追问答案:可能导致内存压力、OS Cache 收缩和并发 Worker 总量爆炸;瓶颈也可能是索引 I/O、WAL、CPU 或 horizon,内存不能解决。

19.3 架构设计题(4 题)

题 12:为十亿行高更新表设计 Autovacuum 策略。

  • 30 秒回答:先测更新/删除速率、单次清理吞吐和允许垃圾窗口,推导每表 base/scale/[PG18] max threshold;为 ANALYZE、insert trigger、freeze、TOAST 分别定策略,并用 admission control 保证生成速率低于清理能力。
  • 深入回答:控制事务时长和索引数量,提高 HOT;对时间型数据分区,封存后 Freeze;按 CPU/I/O/WAL/副本容量设置 Worker 与 cost;监控 trigger-overdue、size slope、VM、XID/MXID time-to-limit。优点是维护小而频繁、P99 平滑;缺点是持续背景成本和策略复杂。替代是批量分区替换/事件模型,但需改数据访问。
  • 面试官真正考察:能否把公式、容量、事务和 HA 统一设计。
  • 常见错误回答:“所有大表 scale factor 设 0,threshold 设 50。”
  • 追问:如何验证策略不是过度 Vacuum?
  • 追问答案:比较每次扫描/清理量、WAL/I/O、前台 P99、VM 收益和文件稳定性;若频繁扫描却几乎无可清理/冻结收益,调整 base/insert/cost,而非只看次数。

题 13:如何设计既支持小时级报表又不让主库 bloat 的只读架构?

  • 30 秒回答:不要让无界报表直接通过 feedback 固定主库 horizon;使用专用延迟副本或快照化数据仓库,设查询/事务时限和资源隔离,并明确可接受的取消、陈旧度与复制延迟。
  • 深入回答:选项包括:普通副本关闭/有界 feedback,让冲突取消;专用延迟副本接受更大 lag;逻辑 ETL 到分析库;定期快照/物化数据。同步副本不应承担无界报表。监控 query age、backend_xmin、conflicts、lag、主库 bloat。优点是隔离 OLTP,缺点是数据新鲜度、成本和管道复杂度。
  • 面试官真正考察:CAP 式取舍、复制冲突与成本归属。
  • 常见错误回答:“在所有副本开 feedback 就同时得到实时和不取消。”
  • 追问:Planned Switchover 前如何处理延迟报表副本?
  • 追问答案:不把明显落后/有旧 horizon 的节点直接提升;先停止报表、追平并验证 timeline/slot/age,或选择健康候选,完成 fencing 与应用重连演练。

题 14:在线修复 5 TB 表的 Heap 与 Index bloat,如何选型?

  • 30 秒回答:先量化 Heap/各 Index/TOAST、可复用空间和是否真的要返还 OS;索引单独用 CONCURRENT REINDEX,Heap 重写在分区迁移、pg_repack 或维护窗 FULL 之间选,并以额外盘、WAL、锁、CDC 和副本容量为硬约束。
  • 深入回答:5 TB 级别通常不能直接 FULL。若按时间分区,可建新分区并迁移/交换;非分区表可分批逻辑迁移或 pg_repack,但持续写入追赶、约 2×额外空间、短 AEL 与长 SUE 都要压测。先清理 horizon,避免把不可删版本复制进新结构。全程设置 lock timeout、暂停 DDL、监控归档/lag,并准备失败残留清理。
  • 面试官真正考察:是否会把“在线”拆成锁窗口、资源峰值和失败恢复。
  • 常见错误回答:“执行 pg_repack 就零停机,剩余空间等于表大小即可。”
  • 追问:何时选择不重写?
  • 追问答案:若空间已稳定复用、查询 Buffers/P99 可接受、磁盘容量足够且重写风险大,bloat 可能是合理稳态;优化触发策略即可。

题 15:设计企业级 Vacuum 健康与自动化平台。

  • 30 秒回答:平台分采集、策略、诊断、审批执行四层;分别监控维护债务、horizon、XID/MXID、WAL/Slot、资源与 HA,阈值按表/服务 SLO 配置,自动化默认只诊断,危险动作需审批和幂等审计。
  • 深入回答:采集来自统计视图、Autovacuum 日志、OS/I/O、pgxpool、复制与归档;策略计算 trigger-overdue、净清理速率和 time-to-limit;诊断关联发布/queryid/blocker;执行器支持普通 VACUUM/ANALYZE、每表 reloptions 和有界并发,FULL/repack/Slot/terminate 走变更流程。多 Primary 场景用 fencing/角色检测,避免在旧主执行。优点是提前治理,缺点是误报、权限和控制面复杂。
  • 面试官真正考察:可观测性、控制面安全、SRE 与数据库知识的结合。
  • 常见错误回答:“定时遍历所有表,dead_pct > 10% 就自动 FULL。”
  • 追问:如何避免自动 Vacuum 形成维护风暴?
  • 追问答案:全局/每节点 semaphore、I/O/WAL/lag admission、优先级队列、随机错峰、上下文取消、最大任务时长和 circuit breaker;任务需幂等并在角色切换后重新确认目标。

20. 练习与参考答案

20.1 理论题(5 题)

理论题 1

题目:一张 800 GB 表经过普通 VACUUM 后,n_dead_tup 大幅下降,但文件仍为 800 GB。能否据此断定 VACUUM 无效?

参考答案:不能。普通 VACUUM 的首要目标是让 Heap/Index 空间在 Relation 内复用,并更新 VM/FSM/冻结边界;中间空页不能直接从文件移除,只有表尾连续空页可能截断。应检查后续写入是否复用、文件净增长斜率、pgstattuple_approx free space、查询 Buffers/Heap Fetches 和 VACUUM 日志。只有业务确实需要返还 OS 且物理低密度造成显著读放大时,才评估 FULL、分区迁移或 pg_repack。

理论题 2

题目:某表 reltuples=400,000,000,base threshold 为 50,scale factor 为 0.2。忽略 PG18 max cap 时,近似触发值是多少?为什么可能不合理?

参考答案:约 50 + 0.2 × 400,000,000 = 80,000,050 个更新/删除。若每秒产生 20,000 个旧版本,要约 4,000 秒才达到阈值,期间还要加 Launcher/Worker 排队和清理时间;对低延迟 OLTP 可能形成巨大读/写/空间放大。应按允许垃圾窗口反推阈值,并验证单次清理吞吐;PG18 可用 max threshold 封顶,但不能替代容量测试。

理论题 3

题目:解释 Slot 的 restart_lsnxmincatalog_xmin,并给出各自风险。

参考答案restart_lsn 是 Slot 仍可能需要的最早 WAL,过老导致 pg_wal/归档存储增长;xmin 阻止普通表中更晚删除的旧版本被 VACUUM 移除;catalog_xmin 对系统目录版本起相似作用,逻辑解码尤其相关。三者可独立变化,处置也不同:恢复消费者可推进全部或部分状态;删除 Slot 会改变 CDC 连续性,必须有重建方案。

理论题 4

题目:为什么只监控 XID age 不足以防止环绕事故?

参考答案:Tuple 被多个事务锁定时会使用 MultiXact ID 和成员存储;热点行、外键、FOR SHARE/KEY SHARE 等可快速消耗 MultiXact。它有独立的 relminmxid/datminmxid、age 和触发/硬限制,可能早于普通 XID 进入风险。必须同时监控 mxid_age()、成员存储增长和热点锁工作负载。

理论题 5

题目:为什么有覆盖索引,查询仍可能需要访问 Heap?

参考答案:索引不存储足够的事务可见性信息;只有 VM 显示对应 Heap Page all-visible 时,Index Only Scan 才能跳过 Heap 检查。近期 DML 清除 VM bit,长事务或 Vacuum 落后阻止恢复,导致 Heap Fetches 上升。替代/修复包括提前 Vacuum、缩短事务、降低 churn、调整索引和查询;强制某个 Plan 不能解决可见性成本。

20.2 实验题(3 题)

实验题 1:比较 HOT 与非 HOT 更新

任务:复制实验一,创建两张结构相同的表。A 表只更新未索引列并设置适当 fillfactor;B 表更新索引列。比较 n_tup_hot_upd/n_tup_upd、Heap/Index size、WAL、Buffers 和 Vacuum 时间。

参考答案要点

  1. 两表装载相同数据、相同初始索引;清空缓存与否必须一致记录;
  2. A 的更新列不能被普通逐行索引引用,并给同页预留空间;B 明确修改索引 key;
  3. 每轮独立提交,记录相同更新行数;
  4. 预期 A 的 HOT 比例更高、索引增长/WAL 较少,但 fillfactor 让初始 Heap 更大;
  5. 若 A HOT 比例仍低,检查页内空间、行宽增长、表达式/部分索引依赖和并发更新;
  6. 不能据单一耗时得出普适结论,必须报告硬件、并发、缓存、P95/P99 和 I/O。

实验题 2:验证 insert-trigger Vacuum 与 Index Only Scan

任务:创建 append-only 表和覆盖索引,批量插入后比较 VACUUM 前后 VM/all-frozen、relallvisible/relpagesHeap Fetches;再插入一批新数据观察 VM 被部分清除。

参考答案要点

  1. 表不做 UPDATE/DELETE,证明“没有 Dead Tuple”仍需 Vacuum;
  2. ANALYZE 后运行覆盖查询记录计划;
  3. 普通或 VACUUM (FREEZE, ANALYZE) 后检查 pg_class.relallvisible/relallfrozen,可选用 pg_visibility
  4. 预期 VACUUM 后 Index Only Scan 的 Heap Fetches 降低;新插入页面尚未 all-visible,再次上升;
  5. VACUUM FREEZE 会增加扫描/WAL,不应仅为追求 0 Heap Fetches 在高频写表反复执行。

实验题 3:评估成本节流、Buffer ring 与 PG18 AIO

任务:在隔离 PG18 环境,对同一可重复数据集分别调整 autovacuum_vacuum_cost_delay/limit 或手工 VACUUM 的 cost、BUFFER_USAGE_LIMIT,并在可选 io_method=worker/io_uring/sync 环境比较维护吞吐和前台 P99。

参考答案要点

  • 每次恢复相同 bloat 起点;记录 shared_buffers、OS Cache 状态、maintenance_io_concurrency、存储类型;
  • 同时采集 progress blocks/s、delay time、pg_stat_io、WAL、Checkpoint、设备队列和前台 P50/P95/P99;
  • 较大 ring/AIO 并发可能加快 Vacuum,也可能挤压热点缓存或存储队列;
  • cost 参数不是精确带宽,跨缓存状态不能直接比较;
  • 结论应是某硬件/工作负载下的容量边界,不是固定推荐值。

20.3 排障题(2 题)

排障题 1

题目:Autovacuum 每 20 分钟都处理一张表,日志显示成功,但表和索引继续增长,n_dead_tup 也不低。给出排查顺序。

参考答案

  1. 确认统计 reset 与实际净数据增长;拆分 Heap/TOAST/每个 Index 大小;
  2. 看 VACUUM VERBOSE/progress 的 not removable、index cycles、phase、WAL 和 duration;
  3. backend_xmin/xact_start 查长事务、idle、Prepared Transaction、Slot 与 feedback;
  4. 检查垃圾生成速率是否高于清理速率,阈值是否触发太晚;
  5. 看 HOT 比例和索引数量,确认是否非 HOT 写放大;
  6. pgstattuple_approx/pgstatindex 验证实际 dead/free/density;
  7. 若 horizon 是根因先释放;若清理吞吐不足,再调每表阈值、cost/Worker/内存并做资源准入;
  8. Heap 已稳定复用则无需缩文件;索引单独异常用 concurrent reindex;必须返还 Heap 空间才评估重写。

排障题 2

题目:告警显示 age(datfrozenxid) 快速上升,pg_wal 也将满;现场有人建议关闭 Autovacuum、删除所有 Slot 并执行 VACUUM FULL。你如何处置?

参考答案

  • 拒绝组合操作:关闭 Autovacuum 会加重环绕;无差别删除 Slot 可能丢 CDC/复制连续性;FULL 需要强锁/额外盘且不是最快 Freeze 路径。
  • 先计算 XID/MXID 消耗斜率与剩余时间,列最老 Database/Relation;
  • 查询最老 transaction/backend_xmin、Prepared Transaction、Slot restart_lsn/xmin/catalog_xmin 和 feedback;
  • WAL 满与 XID horizon 分开处置:恢复消费者/归档,只有在获批的数据重建方案下删除特定 Slot;
  • 释放最老 horizon 后,让 anti-wrap Worker 完成,或对最老表执行普通 VACUUM;紧急时可跳过非必要 Index Cleanup/Truncation,之后补债;
  • 限制非关键写入,保护剩余 XID、I/O 和磁盘;
  • 持续验证 age 下降、WAL 可回收、归档/副本追平,并补充根因修复。

20.4 系统设计题(1 题)

题目

设计一个 24×7 多租户 PostgreSQL 平台的 Vacuum 治理方案:有 20 TB Primary、两个物理副本、逻辑 CDC、Go 微服务和小时级分析查询。要求写入 P99 稳定、RPO 小、不能因环绕停写,并支持在线 bloat 修复。

完整参考答案

数据与事务层:按时间/租户访问模式分区,控制单分区大小;Go 服务使用 pgxpool 有界连接和 goroutine,数据库事务不包含外部 RPC,设置基于业务 P99 的 statement/idle/transaction timeout;写入端有 admission control 和幂等重试。

Autovacuum policy:按每表变更速率和垃圾窗口推导 threshold/scale,PG18 用 max threshold 封顶大表;insert/analyze/freeze/TOAST 单独建模。Worker slots、max workers、cost、内存和 AIO 并发由 CPU/I/O/WAL/Replica 容量压测确定。封存分区执行 ANALYZE/FREEZE,数据淘汰用 DROP/DETACH 分区而非巨量 DELETE。

可观测性:采集表/索引/TOAST size slope、dead trigger overdue、Vacuum progress/time、HOT、VM/Heap Fetches、XID/MXID time-to-limit、长事务/Prepared、Slot 三种 horizon、WAL/Checkpoint/归档、Replica lag/conflict、OS I/O 与 pgxpool acquire P99。阈值按服务和表分级,不使用统一 dead_pct。

复制与分析:同步/异步副本角色分离;承载 RPO 的同步候选不跑无界分析。小时级查询放专用延迟副本或分析库,设置查询时限和有界 feedback;监控 backend_xmin。CDC Slot 有 owner、最大离线时间、WAL 配额、证书/心跳告警和全量重建 Runbook。

在线修复:先诊断 Heap/Index/TOAST;索引使用 REINDEX CONCURRENTLY;Heap 优先分区迁移/交换,非分区场景预演 pg_repack。所有重写需额外盘、WAL/归档/副本容量、lock timeout、DDL freeze、CDC/触发器验证、失败清理和审批。FULL 只用于可接受停写的维护窗。

HA 与安全:维护控制面每次执行前确认当前 Primary 和 fencing token;Failover 后重新检查 Autovacuum、Slot、age 和正在执行任务。备份/PITR 覆盖膨胀与重写峰值,并定期恢复校验。环绕告警按 time-to-trigger 分级,紧急预案优先清 horizon 和普通 VACUUM,禁止自动 FULL/删除 Slot/关闭 Autovacuum。

取舍:更频繁 Vacuum 会增加持续 I/O/WAL,但可降低单次峰值与 bloat;feedback 提高长查询成功率但增加主库空间风险;在线 repack 降低停写却增加磁盘、WAL 和控制面复杂度。设计目标是可预测的资源预算和明确的故障成本,而不是把任一指标压到零。

21. 章节检查清单

  • 我能够解释 LIVE、Recently Dead、Dead 和 Frozen 的状态转换。
  • 我能够解释 Page Pruning/HOT Pruning 为什么不能替代 VACUUM。
  • 我能够画出 Lazy VACUUM 的 Heap 扫描、Index Cleanup、Heap Cleanup、VM/FSM 与 Truncation 路径。
  • 我能够解释普通 VACUUM 为什么通常不把空间返还操作系统。
  • 我能够说明 VACUUM FULL、REINDEX CONCURRENTLY 与 pg_repack 的锁、磁盘、WAL 和失败边界。
  • 我能够按表规模、变更速率和垃圾窗口推导 Autovacuum 阈值,而不是套用统一 scale factor。
  • 我能够诊断长事务、idle in transaction、Prepared Transaction、Slot 与 hot standby feedback 的 horizon。
  • 我能够准确区分 Slot 的 WAL 保留和 Tuple/Catalog 回收影响。
  • 我能够同时监控 XID 与 MultiXact,并计算 time-to-trigger/time-to-stop。
  • 我能够用 pg_stat_all_tablespg_stat_progress_vacuumpg_stat_activitypg_replication_slotspgstattuple 验证问题。
  • 我能够解释 VM 与 Index Only Scan 的 Heap Fetches 关系。
  • 我能够分析 Vacuum 对 CPU、内存、shared buffers、OS Cache、AIO、I/O、WAL、Checkpoint 和复制延迟的影响。
  • 我能够复现实验中的非 HOT bloat、长快照和 FULL 锁等待。
  • 我能够在 Go 中使用 pgxpool、context、有界并发、参数化 SQL、Rows 关闭/错误检查和优雅停机实现健康检查。
  • 我能够区分数据库并发、goroutine、连接数、活跃查询、TPS 和排队请求。
  • 我能够执行环绕、bloat、Slot 和副本冲突 Runbook,并验证修复结果。
  • 我能够评估本章问题对性能、并发、RPO、RTO、备份、PITR 和故障切换的影响。

22. 官方资料来源

以下资料核对日期:2026-06-20。生产实施时仍应按目标小版本重新核对 Release Notes、参数上下限和扩展兼容性。

PostgreSQL 18 官方文档

  1. Routine Vacuuming:日常 VACUUM、空间复用、Freeze、XID/MultiXact 环绕与 Autovacuum。
  2. VACUUM SQL Command:FULL、FREEZE、ANALYZE、INDEX_CLEANUP、TRUNCATE、PARALLEL、BUFFER_USAGE_LIMIT 等语法与锁。
  3. Vacuuming Configuration:Autovacuum Launcher/Worker、threshold/scale/insert、freeze、cost、failsafe 与 PG18 参数。
  4. Resource Consumptionmaintenance_work_memautovacuum_work_memvacuum_buffer_usage_limit、PG18 AIO 参数。
  5. Cumulative Statistics Viewspg_stat_all_tablespg_stat_activitypg_stat_replicationpg_stat_iopg_stat_wal 等。
  6. Progress Reportingpg_stat_progress_vacuumpg_stat_progress_clusterpg_stat_progress_create_index
  7. Free Space MapVisibility MapHOT:Page 辅助结构与 HOT 更新/剪枝。
  8. pg_replication_slotsrestart_lsnxmincatalog_xminwal_status 和失效原因。
  9. Hot Standby:恢复冲突、Standby delay 与 hot_standby_feedback
  10. pgstattuple:精确/近似 Tuple 与 B-tree 物理统计。
  11. REINDEX:普通与 CONCURRENTLY 重建的锁、阶段、失败和额外负载。
  12. PostgreSQL 18 Release Notes:AIO、eager freezing、Worker slots、max threshold、维护耗时统计、cost delay timing 等变化。

PostgreSQL 14—17 Release Notes

  1. PostgreSQL 14 Release Notes:Vacuum failsafe、INDEX_CLEANUP=AUTO、B-tree/Vacuum 改进。
  2. PostgreSQL 15 Release Notes:统计系统与 VACUUM/Autovacuum 日志、冻结推进变化。
  3. PostgreSQL 16 Release NotesBUFFER_USAGE_LIMIT/vacuum_buffer_usage_limit 与 HOT/BRIN 变化。
  4. PostgreSQL 17 Release Notes:VACUUM 内存管理、progress 字段重命名与 old_snapshot_threshold 移除。

PostgreSQL 官方源码

  1. htup_details.h(REL_18_STABLE):Heap Tuple Header、可见性与 HOT 标志。
  2. pruneheap.c(REL_18_STABLE):Page/HOT Pruning、line pointer 重定向与压实。
  3. vacuumlazy.c(REL_18_STABLE):Lazy VACUUM 扫描、Dead TID、索引/Heap 清理与截断状态机。

官方项目资料

  1. pg_repack 官方文档:支持版本、主键要求、约两倍额外空间、锁、触发器日志、限制与失败诊断。
  2. pgx/v5 官方仓库pgxpool API:Context、Pool、Query/Rows、Close、Stat 与参数化执行。